- HERO/SHOWCASE/PROCESS/PROOF/SOFTWARE+CTA 5섹션 다크(--jsm-dark-bg) 재구성 - HeroField WebGL 배경 + -mt-16/pt-16로 상단 라이트 띠 제거 (PublicShell 무수정) - "생각을 동작하는 소프트웨어로." 거대 타이포(clamp, -0.04em) - 경력·소속 표현 전면 제거 → "24시간 돌아가는 실서비스 직접 설계·운영" 신뢰 축 - CountUp 카운트업 스탯 + 스크롤 큐 keyframes(motion-safe 가드) - layout metadata·jsonLd 카피 동일 톤 교체 (jobTitle "소프트웨어 엔지니어") Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
77 lines
2.1 KiB
TypeScript
77 lines
2.1 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
interface Props {
|
|
/** 카운트업 목표 숫자 */
|
|
to: number;
|
|
/** 숫자 앞에 붙는 고정 텍스트 (예: 없음) */
|
|
prefix?: string;
|
|
/** 숫자 뒤에 붙는 고정 텍스트 (예: '+') */
|
|
suffix?: string;
|
|
/** 애니메이션 길이(ms) — 기본 600 */
|
|
duration?: number;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* IntersectionObserver 진입 시 0 → to 로 카운트업.
|
|
* prefers-reduced-motion이면 즉시 최종값 표시(연출 생략).
|
|
* transform/opacity가 아닌 textContent 변경이라 레이아웃 안정 위해 tabular-nums 권장.
|
|
*/
|
|
export default function CountUp({ to, prefix = '', suffix = '', duration = 600, className }: Props) {
|
|
const ref = useRef<HTMLSpanElement>(null);
|
|
const [value, setValue] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
|
|
let rafId = 0;
|
|
let started = false;
|
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
const run = () => {
|
|
// reduced-motion: 즉시 최종값 (연출 생략)
|
|
if (reduced) {
|
|
setValue(to);
|
|
return;
|
|
}
|
|
const start = performance.now();
|
|
const tick = (now: number) => {
|
|
const t = Math.min((now - start) / duration, 1);
|
|
// easeOutCubic — 끝에서 부드럽게 안착
|
|
const eased = 1 - Math.pow(1 - t, 3);
|
|
setValue(Math.round(eased * to));
|
|
if (t < 1) rafId = requestAnimationFrame(tick);
|
|
};
|
|
rafId = requestAnimationFrame(tick);
|
|
};
|
|
|
|
const io = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0]?.isIntersecting && !started) {
|
|
started = true;
|
|
run();
|
|
io.disconnect();
|
|
}
|
|
},
|
|
{ threshold: 0.4 },
|
|
);
|
|
io.observe(el);
|
|
|
|
return () => {
|
|
io.disconnect();
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
};
|
|
}, [to, duration]);
|
|
|
|
return (
|
|
<span ref={ref} className={className} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
{prefix}
|
|
{value.toLocaleString('ko-KR')}
|
|
{suffix}
|
|
</span>
|
|
);
|
|
}
|