키네틱 타이포그래피 랜딩페이지, 코드로 뜯어봤다
키네틱 타이포그래피는 헤드라인 글자가 화면 폭에 맞춰 커지고, 단어가 순서대로 튀어 오르고, 숫자가 굴러가듯 올라가는 디자인 방식입니다. 요즘 랜딩페이지 첫 화면을 다 이렇게 만들길래, 실제로 코드까지 뜯어서 아홉 조각으로 나눠봤습니다.
이 아홉 개 부품을 그대로 이어 붙인 원페이지 랜딩을 먼저 스크롤해 보십시오. 헤드라인이 단어 단위로 떠오르고, 카드 속 숫자가 실제 값까지 굴러 올라가고, 맨 아래 워드마크 위로 빛이 한 번 스쳐 지나가는지 확인하는 것이 핵심입니다.

이 화면을 이루는 아홉 조각은 그대로 가져다 쓰는 키네틱 타이포 템플릿이라, 아래 격자에서 하나씩 따로 클릭해서 열어 볼 수 있습니다. 나열한 순서는 인기순이 아니라 방문자가 실제 랜딩페이지를 위에서 아래로 훑는 동선(눈길이 먼저 가는 곳, 둘러보는 곳, 기능을 훑는 곳, 값을 따지는 곳, 신뢰를 확인하는 곳, 등 떠밀려 행동하는 곳, 마지막에 남는 곳) 그대로 따라간 것입니다. 글자 대신 짙은 배경과 글로우로 분위기를 잡는 쪽은 다크 SaaS 리니어 디자인 시스템이 맡았고, 같은 화면을 말랑한 덩어리로 바꾸면 클레이모피즘 랜딩 키트가 됩니다.
01 hero-type
02 nav-type
03 card-grid-type
04 feature-list-type
05 pricing-type
06 testimonial-type
07 cta-band-type
08 footer-type
09 buttons-inputs-type
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 뷰포트 채우는 헤드라인
- 02 거대 워드마크 내비
- 03 숫자가 거대한 카드
- 04 한 줄 한 기능, 큰 번호
- 05 가격 숫자가 주인공
- 06 초대형 인용문
- 07 마퀴 CTA
- 08 거대 워드마크 푸터
- 09 밑줄 애니메이션 링크 버튼
01히어로
키네틱 타이포그래피는 어떻게 움직이나
헤드라인 세 단어가 순서대로 아래에서 위로 튀어 오르며 나타납니다.

<main class="stage">
<div class="hero is-demo">
<p class="hero__eyebrow">KINETIC</p>
<h1 class="hero__headline">
<span class="word"><span class="word__inner">생각의</span></span>
<span class="word"><span class="word__inner">속도로</span></span>
<span class="word"><span class="word__inner">만든다</span></span>
</h1>
<button class="hero__cta" type="button">지금 시작</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 세 단어가 차례로 올라오는 한 주기
$easing: $ease-spring; // 처음 빠르고 끝은 부드럽게
$color: $subject-blue; // 눈썹 강조 색
@include stage($stage-ink);
.hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.hero__eyebrow {
margin: 0 0 $sp-2;
color: $subject-blue-light; // 먹 무대 위 파란 글자 — AA 4.5 (blog-design 글자 대비)
font-weight: 700;
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1;
letter-spacing: .08em;
text-transform: uppercase;
}
.hero__headline {
margin: 0 0 $sp-4;
display: flex;
flex-direction: column;
align-items: center;
}
// 단어마다 상자 — 마스크 대신 벡터필드 진입으로 바뀌어 overflow 는 visible
// (형태2차 한 겹, 2026-09-14: 글자별 노이즈 오프셋 + 진입 시 회전 힘장을 따라 자리 잡기 — 077 품질기준선)
.word {
overflow: visible;
position: relative;
}
.word__inner {
display: flex;
flex-wrap: nowrap;
}
// 글자 낱개 — index.html 말미 스크립트가 텍스트를 이 span 으로 쪼개고
// 결정론 해시로 --nx0·--ny0·--nr0(출발점) · --wx·--wy·--wr(힘장 경유점) 을 심는다. 프레임은 여기 CSS 가 다 만든다.
.char {
display: inline-block;
font-weight: 800;
font-size: clamp(28px, 8vw, 56px);
line-height: 1.08;
color: $subject-cream;
animation: char-flow $duration $easing infinite;
}
.char--space { width: .28em; }
@keyframes char-flow {
0%, 4% { transform: translate(var(--nx0, 0), var(--ny0, 40px)) rotate(var(--nr0, 0deg)) scale(.55); opacity: 0; }
16% { transform: translate(var(--wx, 0), var(--wy, 14px)) rotate(var(--wr, 0deg)) scale(1.08); opacity: 1; }
30%, 78% { transform: translate(0, 0) rotate(0deg) scale(1); opacity: 1; }
96%, 100% { transform: translate(var(--nx0, 0), var(--ny0, 40px)) rotate(var(--nr0, 0deg)) scale(.55); opacity: 0; }
}
.hero__cta {
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
padding: $sp-2 $sp-6;
font-weight: 700;
font-size: clamp(12px, 3vw, 14px);
line-height: 1;
}
@media (prefers-reduced-motion: reduce) {
.char { animation: none; transform: none; opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.hero__eyebrow {
margin: 0 0 8px;
color: #5b8cf8;
font-weight: 700;
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero__headline {
margin: 0 0 16px;
display: flex;
flex-direction: column;
align-items: center;
}
.word {
overflow: visible;
position: relative;
}
.word__inner {
display: flex;
flex-wrap: nowrap;
}
.char {
display: inline-block;
font-weight: 800;
font-size: clamp(28px, 8vw, 56px);
line-height: 1.08;
color: #fff7e6;
animation: char-flow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.char--space {
width: 0.28em;
}
@keyframes char-flow {
0%, 4% {
transform: translate(var(--nx0, 0), var(--ny0, 40px)) rotate(var(--nr0, 0deg)) scale(0.55);
opacity: 0;
}
16% {
transform: translate(var(--wx, 0), var(--wy, 14px)) rotate(var(--wr, 0deg)) scale(1.08);
opacity: 1;
}
30%, 78% {
transform: translate(0, 0) rotate(0deg) scale(1);
opacity: 1;
}
96%, 100% {
transform: translate(var(--nx0, 0), var(--ny0, 40px)) rotate(var(--nr0, 0deg)) scale(0.55);
opacity: 0;
}
}
.hero__cta {
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
padding: 8px 24px;
font-weight: 700;
font-size: clamp(12px, 3vw, 14px);
line-height: 1;
}
@media (prefers-reduced-motion: reduce) {
.char {
animation: none;
transform: none;
opacity: 1;
}
}// 글자별 노이즈 오프셋 + 벡터필드 진입 (형태2차) — 글자를 span 으로 쪼개고
// 결정론 해시로 출발점/경유점 변수만 심는다. 애니메이션 자체는 style.scss 의 keyframes 가 만든다.
// 원리: 각 글자의 (글자index, 단어index) 로부터 0..1 해시를 3개 뽑아
// 출발점 --nx0/--ny0/--nr0 : 화면 밖 아래쪽, 글자마다 제각각인 흩뿌림
// 경유점 --wx/--wy/--wr : 회전 힘장 v(x,y) = ω×(x,y) 를 출발점에서 dt 만큼 적분한 한 점 —
// 직선이 아니라 힘장을 따라 휘어져 들어온다
(function () {
function hash(n) { // 정수 → 0..1 결정론 난수 (sin 해시 — 077 캔버스 축과 같은 계열)
var s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
document.querySelectorAll('.hero .word__inner').forEach(function (word, w) {
var text = word.textContent;
word.textContent = '';
for (var c = 0; c < text.length; c++) {
var ch = document.createElement('span');
if (text[c] === ' ') { ch.className = 'char char--space'; }
else {
ch.className = 'char';
ch.textContent = text[c];
var s = w * 97 + c * 13 + 5;
var h1 = hash(s), h2 = hash(s + 1), h3 = hash(s + 2), h4 = hash(s + 3);
var ang = h1 * Math.PI * 2; // 흩뿌리는 방향
var rad = 26 + h2 * 46; // 흩뿌리는 반경(px)
var x0 = Math.cos(ang) * rad, y0 = 40 + h3 * 34; // 출발 오프셋
var rot0 = (h4 - .5) * 44; // 출발 기울기(deg)
// 회전 힘장 v(p) = ω × p 를 출발점에서 dt 적분 — v = ω*(-y, x), dt=0.55, ω=0.9
var wx = x0 - 0.9 * y0 * 0.55, wy = y0 + 0.9 * x0 * 0.55;
var wr = rot0 * 0.4; // 경유점에서 절반쯤 펴져 있다
ch.style.setProperty('--nx0', x0.toFixed(1) + 'px');
ch.style.setProperty('--ny0', y0.toFixed(1) + 'px');
ch.style.setProperty('--nr0', rot0.toFixed(1) + 'deg');
ch.style.setProperty('--wx', (wx * .8).toFixed(1) + 'px');
ch.style.setProperty('--wy', (wy * .18 + 8).toFixed(1) + 'px');
ch.style.setProperty('--wr', wr.toFixed(1) + 'deg');
ch.style.animationDelay = (-w * 0.18 - c * 0.045).toFixed(3) + 's';
}
word.appendChild(ch);
}
});
})();import type { CSSProperties } from "react";
import styles from "./KineticHero.module.scss";
export type KineticHeroProps = {
eyebrow?: string;
word1?: string;
word2?: string;
word3?: string;
cta?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CharVars = CSSProperties & {
"--nx0"?: string; "--ny0"?: string; "--nr0"?: string;
"--wx"?: string; "--wy"?: string; "--wr"?: string;
};
/** 정수 → 0..1 결정론 난수 (sin 해시 — vanilla 01 과 동일 계열) */
function hash(n: number): number {
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
/** 한 글자 = 출발점(노이즈) + 힘장 경유점 변수 묶음. vanilla index.html 스크립트와 같은 식. */
function charVars(w: number, c: number): CharVars {
const s = w * 97 + c * 13 + 5;
const h1 = hash(s), h2 = hash(s + 1), h3 = hash(s + 2), h4 = hash(s + 3);
const ang = h1 * Math.PI * 2; // 흩뿌리는 방향
const rad = 26 + h2 * 46; // 흩뿌리는 반경(px)
const x0 = Math.cos(ang) * rad, y0 = 40 + h3 * 34; // 출발 오프셋
const rot0 = (h4 - .5) * 44; // 출발 기울기(deg)
// 회전 힘장 v(p) = ω × p 를 출발점에서 dt 적분 — v = ω*(-y, x), dt=0.55, ω=0.9
const wx = x0 - 0.9 * y0 * 0.55, wy = y0 + 0.9 * x0 * 0.55;
const wr = rot0 * 0.4;
return {
"--nx0": `${x0.toFixed(1)}px`,
"--ny0": `${y0.toFixed(1)}px`,
"--nr0": `${rot0.toFixed(1)}deg`,
"--wx": `${(wx * .8).toFixed(1)}px`,
"--wy": `${(wy * .18 + 8).toFixed(1)}px`,
"--wr": `${wr.toFixed(1)}deg`,
animationDelay: `${(-w * 0.18 - c * 0.045).toFixed(3)}s`,
};
}
export default function KineticHero({
eyebrow = "KINETIC",
word1 = "Build",
word2 = "at the",
word3 = "speed of thought",
cta = "Get started",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: KineticHeroProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const words = [word1, word2, word3];
return (
<div className={styles.stage} style={vars}>
<p className={styles.eyebrow}>{eyebrow}</p>
<h1 className={styles.headline}>
{words.map((w, i) => (
<span key={i} className={styles.word}>
<span className={styles.wordInner}>
{w.split("").map((ch, c) =>
ch === " " ? (
<span key={c} className={styles.charSpace} />
) : (
<span key={c} className={styles.char} style={charVars(i, c)}>{ch}</span>
)
)}
</span>
</span>
))}
</h1>
<button type="button" className={styles.cta}>{cta}</button>
</div>
);
}01 · 뷰포트 채우는 헤드라인 — 측정 누적면적 12.381% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
화면 폭에 맞춰 커진다
각 단어는 보이지 않는 상자 안에 숨어 있다가 차례가 되면 튀어 올라오고, 글자 크기 자체도 화면 폭에 맞춰 clamp()로 자동으로 커지거나 작아집니다.
<section class="flex flex-col items-center gap-6 py-24 text-center">
<p class="text-xs font-bold uppercase tracking-[0.2em] text-accent">
KINETIC TYPE
</p>
<h1 class="font-display text-6xl font-extrabold leading-[1.02]
sm:text-7xl md:text-8xl">
생각의 속도로<br>만든다
</h1>
<button class="rounded-pill bg-ink px-8 py-4 text-base font-bold text-cream">
지금 시작
</button>
</section>
02내비게이션
워드마크가 저절로 줄었다 커진다
내비게이션 왼쪽 워드마크 글자가 저절로 작아졌다가 다시 커지기를 반복합니다.

<main class="stage">
<nav class="navbar is-demo">
<span class="navbar__brand">KINETIC</span>
<div class="navbar__right">
<span class="navbar__link">소개</span>
<span class="navbar__link">가격</span>
<button class="navbar__cta" type="button">시작하기</button>
</div>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 워드마크가 줄었다 돌아오는 한 주기
$easing: $ease-spring;
$color: $subject-ink; // 글자 색
@include stage($stage-yellow);
.navbar {
width: min(400px, 92vw);
display: flex;
align-items: center;
justify-content: space-between;
// 320px 폭에서 브랜드+링크+CTA 의 min-content 합이 92vw 를 넘겨 넘쳤다 (body.scrollWidth 실측 — gap·패딩을 뷰포트에 맞춰 줄인다
gap: clamp($sp-1, 1vw, $sp-4);
background: $subject-cream;
border-radius: $r-pill;
padding: $sp-2 $sp-2 $sp-2 clamp($sp-1, 1.6vw, $sp-4);
box-shadow: $shadow-raised;
}
// 왼쪽 워드마크만 애니메이션한다 — 스크롤에 맞춰 줄어드는 sticky 헤더를 흉내
.navbar__brand {
display: inline-flex;
transform-origin: left center; // 규칙8: 헤더 왼쪽 끝에 붙은 워드마크 — sticky 헤더처럼 좌변을 두고 줄어든다
color: $color;
font-weight: 800;
font-size: clamp(17px, 4.6vw, 23px);
line-height: 1;
letter-spacing: .01em;
animation: brand-shrink $duration $easing infinite;
}
@keyframes brand-shrink {
0%, 15% { transform: scale(1); }
50% { transform: scale(.78); }
85%, 100% { transform: scale(1); }
}
// 워드마크 글자 낱개 — index.html 말미 스크립트가 텍스트를 이 span 으로 쪼개고
// 결정론 해시로 --nx0·--ny0·--nr0(출발점) · --wx·--wy·--wr(힘장 경유점) 을 심는다. (형태2차 한 겹, 077 품질기준선)
.char {
display: inline-block;
animation: char-flow $duration $easing infinite;
}
.char--space { width: .28em; }
@keyframes char-flow {
0%, 4% { transform: translate(var(--nx0, 0), var(--ny0, 14px)) rotate(var(--nr0, 0deg)) scale(.55); opacity: 0; }
16% { transform: translate(var(--wx, 0), var(--wy, 4px)) rotate(var(--wr, 0deg)) scale(1.08); opacity: 1; }
30%, 78% { transform: translate(0, 0) rotate(0deg) scale(1); opacity: 1; }
96%, 100% { transform: translate(var(--nx0, 0), var(--ny0, 14px)) rotate(var(--nr0, 0deg)) scale(.55); opacity: 0; }
}
.navbar__right {
display: flex;
align-items: center;
gap: clamp($sp-1, 1.2vw, $sp-4);
}
.navbar__link {
color: $color;
opacity: .68;
font-weight: 600;
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1;
}
.navbar__cta {
border: 0;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
padding: $sp-2 clamp($sp-1, 1.6vw, $sp-4);
font-weight: 700;
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1;
}
@media (prefers-reduced-motion: reduce) {
.navbar__brand { animation: none; transform: none; }
.char { animation: none; transform: none; opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.navbar {
width: min(400px, 92vw);
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(4px, 1vw, 16px);
background: #fff7e6;
border-radius: 999px;
padding: 8px 8px 8px clamp(4px, 1.6vw, 16px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.navbar__brand {
display: inline-flex;
transform-origin: left center;
color: #17141a;
font-weight: 800;
font-size: clamp(17px, 4.6vw, 23px);
line-height: 1;
letter-spacing: 0.01em;
animation: brand-shrink 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes brand-shrink {
0%, 15% {
transform: scale(1);
}
50% {
transform: scale(0.78);
}
85%, 100% {
transform: scale(1);
}
}
.char {
display: inline-block;
animation: char-flow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.char--space {
width: 0.28em;
}
@keyframes char-flow {
0%, 4% {
transform: translate(var(--nx0, 0), var(--ny0, 14px)) rotate(var(--nr0, 0deg)) scale(0.55);
opacity: 0;
}
16% {
transform: translate(var(--wx, 0), var(--wy, 4px)) rotate(var(--wr, 0deg)) scale(1.08);
opacity: 1;
}
30%, 78% {
transform: translate(0, 0) rotate(0deg) scale(1);
opacity: 1;
}
96%, 100% {
transform: translate(var(--nx0, 0), var(--ny0, 14px)) rotate(var(--nr0, 0deg)) scale(0.55);
opacity: 0;
}
}
.navbar__right {
display: flex;
align-items: center;
gap: clamp(4px, 1.2vw, 16px);
}
.navbar__link {
color: #17141a;
opacity: 0.68;
font-weight: 600;
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1;
}
.navbar__cta {
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
padding: 8px clamp(4px, 1.6vw, 16px);
font-weight: 700;
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1;
}
@media (prefers-reduced-motion: reduce) {
.navbar__brand {
animation: none;
transform: none;
}
.char {
animation: none;
transform: none;
opacity: 1;
}
}// 워드마크 글자별 노이즈 오프셋 + 벡터필드 진입 (형태2차) — KINETIC 7글자를 span 으로 쪼개고
// 결정론 해시로 출발점/경유점 변수만 심는다. 프레임은 style.scss 의 char-flow 키프레임이 만든다.
// 기존 brand-shrink(컨테이너 스케일) 와 겹쳐 — 글자는 흩어졌다 힘장을 따라 모이고, 그 위에 워드마크 전체가 줄었다 커진다.
(function () {
function hash(n) { // 정수 → 0..1 결정론 난수 (sin 해시)
var s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
document.querySelectorAll('.navbar .navbar__brand').forEach(function (brand) {
var text = brand.textContent;
brand.textContent = '';
for (var c = 0; c < text.length; c++) {
var ch = document.createElement('span');
if (text[c] === ' ') { ch.className = 'char char--space'; }
else {
ch.className = 'char';
ch.textContent = text[c];
var s = c * 13 + 5;
var h1 = hash(s), h2 = hash(s + 1), h3 = hash(s + 2), h4 = hash(s + 3);
var ang = h1 * Math.PI * 2;
var rad = 10 + h2 * 18;
var x0 = Math.cos(ang) * rad, y0 = 14 + h3 * 12;
var rot0 = (h4 - .5) * 30;
var wx = x0 - 0.9 * y0 * 0.55, wy = y0 + 0.9 * x0 * 0.55;
var wr = rot0 * 0.4;
ch.style.setProperty('--nx0', x0.toFixed(1) + 'px');
ch.style.setProperty('--ny0', y0.toFixed(1) + 'px');
ch.style.setProperty('--nr0', rot0.toFixed(1) + 'deg');
ch.style.setProperty('--wx', (wx * .6).toFixed(1) + 'px');
ch.style.setProperty('--wy', (wy * .18 + 3).toFixed(1) + 'px');
ch.style.setProperty('--wr', wr.toFixed(1) + 'deg');
ch.style.animationDelay = (-0.05 * c).toFixed(3) + 's';
}
brand.appendChild(ch);
}
});
})();import type { CSSProperties } from "react";
import styles from "./KineticNav.module.scss";
export type KineticNavProps = {
brand?: string;
link1?: string;
link2?: string;
cta?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CharVars = CSSProperties & {
"--nx0"?: string; "--ny0"?: string; "--nr0"?: string;
"--wx"?: string; "--wy"?: string; "--wr"?: string;
};
/** 정수 → 0..1 결정론 난수 (sin 해시 — vanilla 02 와 동일 계열) */
function hash(n: number): number {
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
/** 한 글자 = 출발점(노이즈) + 힘장 경유점 변수 묶음. vanilla index.html 스크립트와 같은 식. */
function charVars(c: number): CharVars {
const s = c * 13 + 5;
const h1 = hash(s), h2 = hash(s + 1), h3 = hash(s + 2), h4 = hash(s + 3);
const ang = h1 * Math.PI * 2;
const rad = 10 + h2 * 18;
const x0 = Math.cos(ang) * rad, y0 = 14 + h3 * 12;
const rot0 = (h4 - .5) * 30;
// 회전 힘장 v(p) = ω × p 를 출발점에서 dt 적분 — v = ω*(-y, x), dt=0.55, ω=0.9
const wx = x0 - 0.9 * y0 * 0.55, wy = y0 + 0.9 * x0 * 0.55;
const wr = rot0 * 0.4;
return {
"--nx0": `${x0.toFixed(1)}px`,
"--ny0": `${y0.toFixed(1)}px`,
"--nr0": `${rot0.toFixed(1)}deg`,
"--wx": `${(wx * .6).toFixed(1)}px`,
"--wy": `${(wy * .18 + 3).toFixed(1)}px`,
"--wr": `${wr.toFixed(1)}deg`,
animationDelay: `${(-0.05 * c).toFixed(3)}s`,
};
}
export default function KineticNav({
brand = "KINETIC",
link1 = "About",
link2 = "Pricing",
cta = "Get started",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: KineticNavProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<nav className={styles.navbar}>
<span className={styles.brand}>
{brand.split("").map((ch, c) =>
ch === " " ? (
<span key={c} className={styles.charSpace} />
) : (
<span key={c} className={styles.char} style={charVars(c)}>{ch}</span>
)
)}
</span>
<div className={styles.right}>
<span className={styles.link}>{link1}</span>
<span className={styles.link}>{link2}</span>
<button type="button" className={styles.cta}>{cta}</button>
</div>
</nav>
</div>
);
}02 · 거대 워드마크 내비 — 측정 누적면적 1.86% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
스크롤과는 무관하다
실제 스크롤에 반응하는 건 아니고, 스크롤하면 로고가 줄어드는 흔한 스티키 헤더 효과를 무한 루프 데모로 미리 보여주는 방식이며, 바뀌는 값은 글자 크기 하나뿐이고 자간은 그대로입니다.
<header class="sticky top-0 z-20 px-6 pt-6">
<nav class="mx-auto flex max-w-5xl items-center justify-between
rounded-pill bg-ink px-6 py-3 shadow-bento">
<span class="font-display text-xl font-extrabold text-cream">MOTIF</span>
<div class="hidden items-center gap-8 sm:flex">
<span class="text-sm font-semibold text-cream/70">소개</span>
<span class="text-sm font-semibold text-cream/70">기능</span>
<span class="text-sm font-semibold text-cream/70">가격</span>
</div>
<button class="rounded-pill bg-accent px-5 py-2 text-sm font-bold text-cream">
시작하기
</button>
</nav>
</header>
03카드 격자
0에서 실제 값까지
카드 안 숫자가 0에서 실제 값(248)까지 자릿수 그대로 올라갑니다.

<main class="stage">
<div class="card is-demo">
<span class="card__num" aria-hidden="true"></span>
<span class="card__sr">프로젝트 248개</span>
<span class="card__label">프로젝트</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 0에서 목표까지 세는 시간
$easing: $ease-out;
$color: $subject-cream; // 숫자 색
@include stage($stage-orange);
// @property: 정수 커스텀 속성이라야 애니메이션 중간값도 정수로 보간된다
@property --num {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.card {
text-align: center;
padding: $sp-6 $sp-8;
border-radius: $r-card;
background: rgba($subject-cream, .08);
border: 1px solid rgba($subject-cream, .18);
&__num {
--num: 0;
counter-reset: num var(--num);
display: block;
font-weight: 800;
font-size: clamp(40px, 13vw, 72px);
line-height: 1;
font-variant-numeric: tabular-nums;
color: $color;
&::before { content: counter(num); }
}
// counter() 생성 콘텐츠는 스크린 리더가 못 읽는다 — 실제 최종값을 시각적으로만 숨겨 따로 둔다
&__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
&__label {
display: block;
margin-top: $sp-2;
font-weight: 600;
font-size: clamp(12px, 3vw, 15px);
line-height: 1;
color: $subject-ink; // 주황 면 위 글자 — AA 4.5 (blog-design 글자 대비)
}
}
.card.is-demo .card__num {
animation: count-up $duration $easing infinite;
}
@keyframes count-up {
0% { --num: 0; }
65% { --num: 248; }
100% { --num: 248; }
}
@media (prefers-reduced-motion: reduce) {
.card.is-demo .card__num { animation: none; --num: 248; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --num {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
.card {
text-align: center;
padding: 24px 32px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.08);
border: 1px solid rgba(255, 247, 230, 0.18);
}
.card__num {
--num: 0;
counter-reset: num var(--num);
display: block;
font-weight: 800;
font-size: clamp(40px, 13vw, 72px);
line-height: 1;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.card__num::before {
content: counter(num);
}
.card__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.card__label {
display: block;
margin-top: 8px;
font-weight: 600;
font-size: clamp(12px, 3vw, 15px);
line-height: 1;
color: #17141a;
}
.card.is-demo .card__num {
animation: count-up 2s ease-out infinite;
}
@keyframes count-up {
0% {
--num: 0;
}
65% {
--num: 248;
}
100% {
--num: 248;
}
}
@media (prefers-reduced-motion: reduce) {
.card.is-demo .card__num {
animation: none;
--num: 248;
}
}import type { CSSProperties } from "react";
import styles from "./KineticCardGrid.module.scss";
export type KineticCardGridProps = {
value?: number;
label?: string;
/** 0에서 목표까지 세는 시간(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--num-target"?: number };
export default function KineticCardGrid({
value = 248,
label = "Projects",
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
}: KineticCardGridProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--num-target": value };
return (
<div className={styles.stage} style={vars}>
<div className={styles.card}>
<span className={styles.num} aria-hidden="true" />
<span className={styles.sr}>{value} {label.toLowerCase()}</span>
<span className={styles.label}>{label}</span>
</div>
</div>
);
}03 · 숫자가 거대한 카드 — 측정 누적면적 9.127% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
스크린리더를 위한 숨은 텍스트
counter()로 그려낸 숫자는 스크린리더가 읽지 못해, 숫자가 다 오른 최종 값("프로젝트 248개")을 시각적으로만 숨긴 텍스트로 따로 붙여 뒀습니다.
<section class="py-14">
<h2 class="mb-8 font-display text-3xl font-extrabold">숫자로 보는 결과</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-tile bg-ink p-8 text-center shadow-bento">
<p class="font-display text-6xl font-extrabold tabular-nums text-cream">
248
</p>
<p class="mt-2 text-sm font-semibold text-cream/70">프로젝트</p>
</div>
<div class="rounded-tile bg-ink p-8 text-center shadow-bento">
<p class="font-display text-6xl font-extrabold tabular-nums text-cream">
99.9%
</p>
<p class="mt-2 text-sm font-semibold text-cream/70">가동률</p>
</div>
</div>
</section>
04기능 목록
세 줄이 순서대로 밀린다
기능 목록 세 줄이 위에서부터 순서대로 오른쪽으로 살짝 밀렸다가 제자리로 돌아오고, 그 아래 밑줄도 같은 박자로 자랐다 사라집니다.

<main class="stage">
<ul class="feature-list is-demo">
<li class="feature-list__row">
<span class="feature-list__index">01</span>
<span class="feature-list__label">실시간 협업</span>
</li>
<li class="feature-list__row">
<span class="feature-list__index">02</span>
<span class="feature-list__label">자동 배포</span>
</li>
<li class="feature-list__row">
<span class="feature-list__index">03</span>
<span class="feature-list__label">팀 대시보드</span>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 파도가 세 줄을 한 번 훑고 지나가는 주기
$easing: $ease-spring;
$color: $subject-blue; // 번호 강조 색
@include stage($stage-paper);
.feature-list {
margin: 0;
padding: 0;
list-style: none;
width: min(340px, 88vw);
&__row {
display: grid;
grid-template-columns: auto 1fr;
align-items: baseline;
gap: $sp-4;
padding: $sp-3 0;
animation: row-shift $duration $easing infinite;
}
// 세 줄이 순서대로 오른쪽으로 살짝 밀렸다 돌아온다 — 아래 밑줄 스윕과 같은 박자로
&__row:nth-child(1) { animation-delay: 0s; }
&__row:nth-child(2) { animation-delay: -0.25s; }
&__row:nth-child(3) { animation-delay: -0.5s; }
&__index {
color: $color;
font-weight: 800;
font-size: clamp(20px, 6vw, 32px);
line-height: 1;
font-variant-numeric: tabular-nums;
}
&__label {
position: relative;
display: inline-block;
color: $subject-ink;
font-weight: 700;
font-size: clamp(14px, 4vw, 20px);
line-height: 1.2;
&::after {
content: '';
position: absolute;
left: 0; right: 0; bottom: -4px;
height: 2px;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 그어짐
transform: scaleX(0);
animation: underline-grow $duration $easing infinite;
}
}
&__row:nth-child(1) &__label::after { animation-delay: 0s; }
&__row:nth-child(2) &__label::after { animation-delay: -0.25s; }
&__row:nth-child(3) &__label::after { animation-delay: -0.5s; }
}
// 라벨 글자 낱개 — index.html 말미 스크립트가 첫 텍스트 노드를 이 span 으로 쪼개고
// 결정론 해시로 --nx0·--ny0·--nr0(출발점) · --wx·--wy·--wr(힘장 경유점) 을 심는다. (형태2차 한 겹, 077 품질기준선)
.char {
display: inline-block;
animation: char-flow $duration $easing infinite;
}
.char--space { width: .28em; }
@keyframes char-flow {
0%, 4% { transform: translate(var(--nx0, 0), var(--ny0, 10px)) rotate(var(--nr0, 0deg)) scale(.55); opacity: 0; }
16% { transform: translate(var(--wx, 0), var(--wy, 3px)) rotate(var(--wr, 0deg)) scale(1.08); opacity: 1; }
30%, 78% { transform: translate(0, 0) rotate(0deg) scale(1); opacity: 1; }
96%, 100% { transform: translate(var(--nx0, 0), var(--ny0, 10px)) rotate(var(--nr0, 0deg)) scale(.55); opacity: 0; }
}
@keyframes row-shift {
0%, 10% { transform: translateX(0); }
50% { transform: translateX(10px); }
90%,100% { transform: translateX(0); }
}
@keyframes underline-grow {
0%, 10% { transform: scaleX(0); }
50% { transform: scaleX(1); }
90%,100% { transform: scaleX(0); }
}
@media (prefers-reduced-motion: reduce) {
.feature-list__row { animation: none; transform: none; }
.feature-list__label::after { animation: none; transform: scaleX(0); }
.char { animation: none; transform: none; opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.feature-list {
margin: 0;
padding: 0;
list-style: none;
width: min(340px, 88vw);
}
.feature-list__row {
display: grid;
grid-template-columns: auto 1fr;
align-items: baseline;
gap: 16px;
padding: 12px 0;
animation: row-shift 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.feature-list__row:nth-child(1) {
animation-delay: 0s;
}
.feature-list__row:nth-child(2) {
animation-delay: -0.25s;
}
.feature-list__row:nth-child(3) {
animation-delay: -0.5s;
}
.feature-list__index {
color: #2f6df6;
font-weight: 800;
font-size: clamp(20px, 6vw, 32px);
line-height: 1;
font-variant-numeric: tabular-nums;
}
.feature-list__label {
position: relative;
display: inline-block;
color: #17141a;
font-weight: 700;
font-size: clamp(14px, 4vw, 20px);
line-height: 1.2;
}
.feature-list__label::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -4px;
height: 2px;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0);
animation: underline-grow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.feature-list__row:nth-child(1) .feature-list__label::after {
animation-delay: 0s;
}
.feature-list__row:nth-child(2) .feature-list__label::after {
animation-delay: -0.25s;
}
.feature-list__row:nth-child(3) .feature-list__label::after {
animation-delay: -0.5s;
}
.char {
display: inline-block;
animation: char-flow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.char--space {
width: 0.28em;
}
@keyframes char-flow {
0%, 4% {
transform: translate(var(--nx0, 0), var(--ny0, 10px)) rotate(var(--nr0, 0deg)) scale(0.55);
opacity: 0;
}
16% {
transform: translate(var(--wx, 0), var(--wy, 3px)) rotate(var(--wr, 0deg)) scale(1.08);
opacity: 1;
}
30%, 78% {
transform: translate(0, 0) rotate(0deg) scale(1);
opacity: 1;
}
96%, 100% {
transform: translate(var(--nx0, 0), var(--ny0, 10px)) rotate(var(--nr0, 0deg)) scale(0.55);
opacity: 0;
}
}
@keyframes row-shift {
0%, 10% {
transform: translateX(0);
}
50% {
transform: translateX(10px);
}
90%, 100% {
transform: translateX(0);
}
}
@keyframes underline-grow {
0%, 10% {
transform: scaleX(0);
}
50% {
transform: scaleX(1);
}
90%, 100% {
transform: scaleX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.feature-list__row {
animation: none;
transform: none;
}
.feature-list__label::after {
animation: none;
transform: scaleX(0);
}
.char {
animation: none;
transform: none;
opacity: 1;
}
}// 라벨 글자별 노이즈 오프셋 + 벡터필드 진입 (형태2차) — 각 라벨을 span 으로 쪼개고
// 결정론 해시로 출발점/경유점 변수만 심는다. 프레임은 style.scss 의 char-flow 키프레임이 만든다.
// 기존 row-shift·underline-grow 와 겹쳐 — 글자는 흩어졌다 힘장을 따라 자리 잡고, 줄 전체는 밀리며 밑줄이 그어진다.
(function () {
function hash(n) { // 정수 → 0..1 결정론 난수 (sin 해시)
var s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
document.querySelectorAll('.feature-list .feature-list__label').forEach(function (label, r) {
var text = label.childNodes.length ? label.textContent : '';
// 밑줄 ::after 는 텍스트 노드가 아니니 안전 — 첫 텍스트 노드만 쪼갠다
var walker = label.firstChild;
if (!walker || walker.nodeType !== 3) return;
var frag = document.createDocumentFragment();
for (var c = 0; c < walker.textContent.length; c++) {
var t = walker.textContent[c];
var ch = document.createElement('span');
if (t === ' ') { ch.className = 'char char--space'; }
else {
ch.className = 'char';
ch.textContent = t;
var s = r * 89 + c * 13 + 5;
var h1 = hash(s), h2 = hash(s + 1), h3 = hash(s + 2), h4 = hash(s + 3);
var ang = h1 * Math.PI * 2;
var rad = 8 + h2 * 15;
var x0 = Math.cos(ang) * rad, y0 = 10 + h3 * 10;
var rot0 = (h4 - .5) * 26;
var wx = x0 - 0.9 * y0 * 0.55, wy = y0 + 0.9 * x0 * 0.55;
var wr = rot0 * 0.4;
ch.style.setProperty('--nx0', x0.toFixed(1) + 'px');
ch.style.setProperty('--ny0', y0.toFixed(1) + 'px');
ch.style.setProperty('--nr0', rot0.toFixed(1) + 'deg');
ch.style.setProperty('--wx', (wx * .6).toFixed(1) + 'px');
ch.style.setProperty('--wy', (wy * .18 + 2).toFixed(1) + 'px');
ch.style.setProperty('--wr', wr.toFixed(1) + 'deg');
ch.style.animationDelay = (-0.14 * r - 0.04 * c).toFixed(3) + 's';
}
frag.appendChild(ch);
}
label.replaceChild(frag, walker);
});
})();import type { CSSProperties } from "react";
import styles from "./KineticFeatureList.module.scss";
export type KineticFeatureListProps = {
features?: string[];
/** 파도가 세 줄을 한 번 훑고 지나가는 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CharVars = CSSProperties & {
"--nx0"?: string; "--ny0"?: string; "--nr0"?: string;
"--wx"?: string; "--wy"?: string; "--wr"?: string;
};
/** 정수 → 0..1 결정론 난수 (sin 해시 — vanilla 04 와 동일 계열) */
function hash(n: number): number {
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
/** 한 글자 = 출발점(노이즈) + 힘장 경유점 변수 묶음. vanilla index.html 스크립트와 같은 식. */
function charVars(r: number, c: number): CharVars {
const s = r * 89 + c * 13 + 5;
const h1 = hash(s), h2 = hash(s + 1), h3 = hash(s + 2), h4 = hash(s + 3);
const ang = h1 * Math.PI * 2;
const rad = 8 + h2 * 15;
const x0 = Math.cos(ang) * rad, y0 = 10 + h3 * 10;
const rot0 = (h4 - .5) * 26;
// 회전 힘장 v(p) = ω × p 를 출발점에서 dt 적분 — v = ω*(-y, x), dt=0.55, ω=0.9
const wx = x0 - 0.9 * y0 * 0.55, wy = y0 + 0.9 * x0 * 0.55;
const wr = rot0 * 0.4;
return {
"--nx0": `${x0.toFixed(1)}px`,
"--ny0": `${y0.toFixed(1)}px`,
"--nr0": `${rot0.toFixed(1)}deg`,
"--wx": `${(wx * .6).toFixed(1)}px`,
"--wy": `${(wy * .18 + 2).toFixed(1)}px`,
"--wr": `${wr.toFixed(1)}deg`,
animationDelay: `${(-0.14 * r - 0.04 * c).toFixed(3)}s`,
};
}
export default function KineticFeatureList({
features = ["Real-time collaboration", "Auto-deploy", "Team dashboard"],
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: KineticFeatureListProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<ul className={styles.list}>
{features.map((label, i) => {
const delay = `${-i * 0.25}s`;
return (
<li key={label} className={styles.row} style={{ animationDelay: delay }}>
<span className={styles.index}>{String(i + 1).padStart(2, "0")}</span>
<span className={styles.label}>
{label.split("").map((ch, c) =>
ch === " " ? (
<span key={c} className={styles.charSpace} />
) : (
<span key={c} className={styles.char} style={charVars(i, c)}>{ch}</span>
)
)}
<span className={styles.underline} style={{ animationDelay: delay }} />
</span>
</li>
);
})}
</ul>
</div>
);
}04 · 한 줄 한 기능, 큰 번호 — 측정 누적면적 7.512% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
번호는 장식일 뿐
왼쪽 큰 번호는 코드 에디터의 줄 번호 같은 인상을 주는 장식이고, 실제 기능 이름은 항상 글자로 붙어 있어 번호만으로 의미가 끊기지 않습니다.
<ul class="divide-y divide-line rounded-tile bg-white shadow-bento">
<li class="grid grid-cols-[auto_1fr] items-baseline gap-6 p-6">
<span class="font-display text-3xl font-extrabold text-accent
tabular-nums">01</span>
<span class="text-lg font-bold">실시간 협업</span>
</li>
<li class="grid grid-cols-[auto_1fr] items-baseline gap-6 p-6">
<span class="font-display text-3xl font-extrabold text-accent
tabular-nums">02</span>
<span class="text-lg font-bold">자동 배포</span>
</li>
</ul>
05가격
월 요금과 연 요금 사이
가격 숫자가 월 요금(19,000원)과 연 요금(190,000원) 사이를 자동으로 오르내리며 반복 재생됩니다.

<main class="stage">
<div class="price is-demo">
<p class="price__plan">프로 플랜</p>
<div class="price__amount">
<span class="price__currency">₩</span>
<span class="price__num" aria-hidden="true"></span>
<span class="price__sr">월 19,000원 · 연 190,000원</span>
</div>
<p class="price__suffix">/ 월</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 월간 ↔ 연간 가격이 한 번 굴러가는 주기
$easing: $ease-out;
$color: $subject-cream; // 가격 숫자 색
@include stage($stage-ink);
// @property: 정수 커스텀 속성이라야 애니메이션 중간값도 정수로 보간된다 (03 과 같은 기법)
@property --num {
syntax: '<integer>';
initial-value: 19000;
inherits: false;
}
.price {
text-align: center;
&__plan {
margin: 0 0 $sp-2;
color: rgba($subject-cream, .7);
font-weight: 700;
font-size: clamp(12px, 3vw, 14px);
line-height: 1;
text-transform: uppercase;
letter-spacing: .06em;
}
&__amount {
display: flex;
align-items: flex-start;
justify-content: center;
gap: $sp-1;
}
&__currency {
color: $color;
font-weight: 700;
font-size: clamp(18px, 5vw, 28px);
line-height: 1.4;
}
&__num {
--num: 19000;
counter-reset: num var(--num);
display: block;
font-weight: 800;
font-size: clamp(32px, 10vw, 56px);
line-height: 1;
font-variant-numeric: tabular-nums;
color: $color;
&::before { content: counter(num); }
}
// counter() 는 스크린 리더가 못 읽는다 — 실제 값을 시각적으로만 숨겨 따로 둔다
&__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
&__suffix {
margin: $sp-2 0 0;
color: rgba($subject-cream, .6);
font-weight: 600;
font-size: clamp(12px, 3vw, 14px);
line-height: 1;
}
}
.price.is-demo .price__num {
animation: price-roll $duration $easing infinite;
}
@keyframes price-roll {
0%, 10% { --num: 19000; }
45%, 55% { --num: 190000; }
90%, 100% { --num: 19000; }
}
@media (prefers-reduced-motion: reduce) {
.price.is-demo .price__num { animation: none; --num: 19000; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --num {
syntax: "<integer>";
initial-value: 19000;
inherits: false;
}
.price {
text-align: center;
}
.price__plan {
margin: 0 0 8px;
color: rgba(255, 247, 230, 0.7);
font-weight: 700;
font-size: clamp(12px, 3vw, 14px);
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.price__amount {
display: flex;
align-items: flex-start;
justify-content: center;
gap: 4px;
}
.price__currency {
color: #fff7e6;
font-weight: 700;
font-size: clamp(18px, 5vw, 28px);
line-height: 1.4;
}
.price__num {
--num: 19000;
counter-reset: num var(--num);
display: block;
font-weight: 800;
font-size: clamp(32px, 10vw, 56px);
line-height: 1;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.price__num::before {
content: counter(num);
}
.price__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.price__suffix {
margin: 8px 0 0;
color: rgba(255, 247, 230, 0.6);
font-weight: 600;
font-size: clamp(12px, 3vw, 14px);
line-height: 1;
}
.price.is-demo .price__num {
animation: price-roll 2s ease-out infinite;
}
@keyframes price-roll {
0%, 10% {
--num: 19000;
}
45%, 55% {
--num: 190000;
}
90%, 100% {
--num: 19000;
}
}
@media (prefers-reduced-motion: reduce) {
.price.is-demo .price__num {
animation: none;
--num: 19000;
}
}import type { CSSProperties } from "react";
import styles from "./KineticPricing.module.scss";
export type KineticPricingProps = {
planName?: string;
currency?: string;
monthly?: number;
yearly?: number;
suffix?: string;
/** 월간 ↔ 연간 가격이 한 번 굴러가는 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string; "--easing"?: string; "--color"?: string;
"--num-from"?: number; "--num-to"?: number;
};
export default function KineticPricing({
planName = "Pro plan",
currency = "$",
monthly = 19,
yearly = 190,
suffix = "/ mo",
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
}: KineticPricingProps) {
const vars: Vars = {
"--duration": `${duration}ms`, "--easing": easing, "--color": color,
"--num-from": monthly, "--num-to": yearly,
};
return (
<div className={styles.stage} style={vars}>
<div className={styles.price}>
<p className={styles.plan}>{planName}</p>
<div className={styles.amount}>
<span className={styles.currency}>{currency}</span>
<span className={styles.num} aria-hidden="true" />
<span className={styles.sr}>{currency}{monthly}{suffix} · {currency}{yearly}/yr</span>
</div>
<p className={styles.suffix}>{suffix}</p>
</div>
</div>
);
}05 · 가격 숫자가 주인공 — 측정 누적면적 4.107% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
진짜 토글을 붙일 자리
버튼을 눌러야 바뀌는 토글이 아니라 처음부터 끝까지 저절로 움직이는 데모이고, 실제 서비스에 붙일 때는 이 자리에 진짜 월간·연간 전환 버튼을 달면 됩니다.
<div class="flex flex-col items-end gap-4 sm:flex-row">
<div class="flex-1 rounded-tile bg-white/60 p-8
shadow-[0_6px_12px_rgba(23,20,26,.08)]">
<p class="font-bold">무료</p>
<p class="mt-2 font-display text-4xl font-extrabold tabular-nums">₩0</p>
</div>
<div class="relative flex-[1.1] rounded-tile bg-ink p-8 pt-10
shadow-bento-hover">
<span class="absolute -top-3 left-8 rounded-pill bg-accent px-3 py-1
text-xs font-bold text-cream">추천</span>
<p class="font-bold text-cream/70">프로 플랜</p>
<p class="mt-2 font-display text-5xl font-extrabold tabular-nums
text-cream">
₩19,000<span class="text-lg font-semibold text-cream/60"> / 월</span>
</p>
</div>
</div>
06후기
커튼을 걷듯 드러난다
초대형 인용문이 왼쪽부터 오른쪽으로 커튼을 걷듯 한 번에 드러납니다.

<main class="stage">
<figure class="quote is-demo">
<p class="quote__text">“속도가 팀의 문화를 바꿨다”</p>
<figcaption class="quote__cite">박지훈 · 그로스 리드</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 왼쪽에서 오른쪽으로 한 번 걷히는 주기
$easing: $ease-out;
$color: $subject-ink; // 인용문 색
@include stage($stage-yellow);
.quote {
margin: 0;
text-align: center;
width: min(360px, 90vw);
}
// clip-path 로 왼쪽→오른쪽 와이프 — 01 의 세로 마스크와는 다른 방향
.quote__text {
margin: 0;
color: $color;
font-weight: 800;
font-size: clamp(22px, 6.5vw, 36px);
line-height: 1.3;
animation: wipe-reveal $duration $easing infinite;
}
@keyframes wipe-reveal {
0%, 4% { clip-path: inset(0 100% 0 0); }
55%, 80% { clip-path: inset(0 0 0 0); }
96%, 100% { clip-path: inset(0 100% 0 0); }
}
// 인용문 글자 낱개 — index.html 말미 스크립트가 텍스트를 이 span 으로 쪼개고
// 결정론 해시로 --nx0·--ny0·--nr0(출발점) · --wx·--wy·--wr(힘장 경유점) 을 심는다. (형태2차 한 겹, 077 품질기준선)
// 와이프와 겹쳐 — 걷히는 영역 안에서 글자가 흩어졌다 힘장을 따라 자리 잡는다
.char {
display: inline-block;
animation: char-flow $duration $ease-spring infinite;
}
.char--space { width: .28em; }
@keyframes char-flow {
0%, 4% { transform: translate(var(--nx0, 0), var(--ny0, 18px)) rotate(var(--nr0, 0deg)) scale(.55); opacity: 0; }
16% { transform: translate(var(--wx, 0), var(--wy, 5px)) rotate(var(--wr, 0deg)) scale(1.08); opacity: 1; }
30%, 78% { transform: translate(0, 0) rotate(0deg) scale(1); opacity: 1; }
96%, 100% { transform: translate(var(--nx0, 0), var(--ny0, 18px)) rotate(var(--nr0, 0deg)) scale(.55); opacity: 0; }
}
.quote__cite {
display: block;
margin-top: $sp-4;
color: rgba($subject-ink, .72); // AA 4.5 (blog-design 글자 대비)
font-weight: 600;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
}
@media (prefers-reduced-motion: reduce) {
.quote__text { animation: none; clip-path: inset(0 0 0 0); }
.char { animation: none; transform: none; opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.quote {
margin: 0;
text-align: center;
width: min(360px, 90vw);
}
.quote__text {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(22px, 6.5vw, 36px);
line-height: 1.3;
animation: wipe-reveal 2s ease-out infinite;
}
@keyframes wipe-reveal {
0%, 4% {
clip-path: inset(0 100% 0 0);
}
55%, 80% {
clip-path: inset(0 0 0 0);
}
96%, 100% {
clip-path: inset(0 100% 0 0);
}
}
.char {
display: inline-block;
animation: char-flow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.char--space {
width: 0.28em;
}
@keyframes char-flow {
0%, 4% {
transform: translate(var(--nx0, 0), var(--ny0, 18px)) rotate(var(--nr0, 0deg)) scale(0.55);
opacity: 0;
}
16% {
transform: translate(var(--wx, 0), var(--wy, 5px)) rotate(var(--wr, 0deg)) scale(1.08);
opacity: 1;
}
30%, 78% {
transform: translate(0, 0) rotate(0deg) scale(1);
opacity: 1;
}
96%, 100% {
transform: translate(var(--nx0, 0), var(--ny0, 18px)) rotate(var(--nr0, 0deg)) scale(0.55);
opacity: 0;
}
}
.quote__cite {
display: block;
margin-top: 16px;
color: rgba(23, 20, 26, 0.72);
font-weight: 600;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
}
@media (prefers-reduced-motion: reduce) {
.quote__text {
animation: none;
clip-path: inset(0 0 0 0);
}
.char {
animation: none;
transform: none;
opacity: 1;
}
}// 인용문 글자별 노이즈 오프셋 + 벡터필드 진입 (형태2차) — 문장을 span 으로 쪼개고
// 결정론 해시로 출발점/경유점 변수만 심는다. 프레임은 style.scss 의 char-flow 키프레임이 만든다.
// 기존 wipe-reveal(clip-path 와이프) 과 겹쳐 — 와이프가 지나가는 동안 글자가 흩어졌다 힘장을 따라 자리 잡는다.
(function () {
function hash(n) { // 정수 → 0..1 결정론 난수 (sin 해시)
var s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
document.querySelectorAll('.quote .quote__text').forEach(function (q) {
var text = q.textContent;
q.textContent = '';
for (var c = 0; c < text.length; c++) {
var ch = document.createElement('span');
if (text[c] === ' ') { ch.className = 'char char--space'; }
else {
ch.className = 'char';
ch.textContent = text[c];
var s = c * 13 + 5;
var h1 = hash(s), h2 = hash(s + 1), h3 = hash(s + 2), h4 = hash(s + 3);
var ang = h1 * Math.PI * 2;
var rad = 14 + h2 * 26;
var x0 = Math.cos(ang) * rad - 18, y0 = 18 + h3 * 16; // 왼쪽 위에서 시작해 오른쪽 아래로 흐른다
var rot0 = (h4 - .5) * 34;
var wx = x0 - 0.9 * y0 * 0.55, wy = y0 + 0.9 * x0 * 0.55;
var wr = rot0 * 0.4;
ch.style.setProperty('--nx0', x0.toFixed(1) + 'px');
ch.style.setProperty('--ny0', y0.toFixed(1) + 'px');
ch.style.setProperty('--nr0', rot0.toFixed(1) + 'deg');
ch.style.setProperty('--wx', (wx * .6).toFixed(1) + 'px');
ch.style.setProperty('--wy', (wy * .18 + 3).toFixed(1) + 'px');
ch.style.setProperty('--wr', wr.toFixed(1) + 'deg');
ch.style.animationDelay = (-0.035 * c).toFixed(3) + 's';
}
q.appendChild(ch);
}
});
})();import type { CSSProperties } from "react";
import styles from "./KineticTestimonial.module.scss";
export type KineticTestimonialProps = {
quote?: string;
cite?: string;
/** 왼쪽에서 오른쪽으로 한 번 걷히는 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CharVars = CSSProperties & {
"--nx0"?: string; "--ny0"?: string; "--nr0"?: string;
"--wx"?: string; "--wy"?: string; "--wr"?: string;
};
/** 정수 → 0..1 결정론 난수 (sin 해시 — vanilla 06 과 동일 계열) */
function hash(n: number): number {
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
/** 한 글자 = 출발점(노이즈) + 힘장 경유점 변수 묶음. vanilla index.html 스크립트와 같은 식. */
function charVars(c: number): CharVars {
const s = c * 13 + 5;
const h1 = hash(s), h2 = hash(s + 1), h3 = hash(s + 2), h4 = hash(s + 3);
const ang = h1 * Math.PI * 2;
const rad = 14 + h2 * 26;
const x0 = Math.cos(ang) * rad - 18, y0 = 18 + h3 * 16; // 왼쪽 위에서 시작해 오른쪽 아래로 흐른다
const rot0 = (h4 - .5) * 34;
// 회전 힘장 v(p) = ω × p 를 출발점에서 dt 적분 — v = ω*(-y, x), dt=0.55, ω=0.9
const wx = x0 - 0.9 * y0 * 0.55, wy = y0 + 0.9 * x0 * 0.55;
const wr = rot0 * 0.4;
return {
"--nx0": `${x0.toFixed(1)}px`,
"--ny0": `${y0.toFixed(1)}px`,
"--nr0": `${rot0.toFixed(1)}deg`,
"--wx": `${(wx * .6).toFixed(1)}px`,
"--wy": `${(wy * .18 + 3).toFixed(1)}px`,
"--wr": `${wr.toFixed(1)}deg`,
animationDelay: `${(-0.035 * c).toFixed(3)}s`,
};
}
export default function KineticTestimonial({
quote = "“Speed changed how our team works.”",
cite = "Park Ji-hoon · Growth Lead",
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: KineticTestimonialProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<figure className={styles.quote}>
<p className={styles.text}>
{quote.split("").map((ch, c) =>
ch === " " ? (
<span key={c} className={styles.charSpace} />
) : (
<span key={c} className={styles.char} style={charVars(c)}>{ch}</span>
)
)}
</p>
<figcaption className={styles.cite}>{cite}</figcaption>
</figure>
</div>
);
}06 · 초대형 인용문 — 측정 누적면적 7.105% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
01과 다른 마스크 방향
01번 헤드라인의 위아래 마스크와 달리 이번엔 clip-path로 좌우 방향으로 걷어내는 와이프 기법을 씁니다.
<section class="py-14">
<h2 class="mb-8 font-display text-3xl font-extrabold">고객 후기</h2>
<figure class="rounded-tile bg-white p-10 text-center shadow-bento">
<p class="font-display text-3xl font-extrabold leading-snug sm:text-4xl">
"속도가 팀의 문화를 바꿨다"
</p>
<figcaption class="mt-4 text-sm text-ink/60">박지훈 · 그로스 리드</figcaption>
</figure>
</section>
07CTA 띠
끊김 없이 흐르는 문구
가로로 긴 띠 안에서 CTA 문구가 끊김 없이 계속 흘러갑니다.

<main class="stage">
<div class="cta-band">
<div class="cta-band__track">
<span class="cta-band__item">지금 시작하기 —</span>
<span class="cta-band__item">지금 시작하기 —</span>
<span class="cta-band__item">지금 시작하기 —</span>
<span class="cta-band__item">지금 시작하기 —</span>
<span class="cta-band__item">지금 시작하기 —</span>
<span class="cta-band__item">지금 시작하기 —</span>
<span class="cta-band__item" aria-hidden="true">지금 시작하기 —</span>
<span class="cta-band__item" aria-hidden="true">지금 시작하기 —</span>
<span class="cta-band__item" aria-hidden="true">지금 시작하기 —</span>
<span class="cta-band__item" aria-hidden="true">지금 시작하기 —</span>
<span class="cta-band__item" aria-hidden="true">지금 시작하기 —</span>
<span class="cta-band__item" aria-hidden="true">지금 시작하기 —</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 실제 서비스는 12~20s 가 보통. 데모는 gif 루프에 맞춰 2s 로 배속
$easing: linear; // 등속
$color: $subject-cream; // 문구 색
@include stage($stage-orange);
.cta-band {
width: min(440px, 94vw);
overflow: hidden;
}
.cta-band__track {
display: flex;
width: max-content;
animation: marquee-scroll $duration $easing infinite;
}
// 6개를 그대로 한 번 더 복제해 트랙 하나에 12개. 간격은 트랙의 gap 이 아니라 margin-right 로 —
// gap 은 12개 사이에 11번만 생겨 -50% 가 gap 의 절반만큼 모자라 한 바퀴마다 튄다 (028 실측, BD15)
.cta-band__item {
margin-right: $sp-6;
flex-shrink: 0;
white-space: nowrap;
color: $color;
font-weight: 800;
font-size: clamp(24px, 7vw, 40px);
line-height: 1;
}
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.cta-band__track { animation: none; transform: translateX(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cta-band {
width: min(440px, 94vw);
overflow: hidden;
}
.cta-band__track {
display: flex;
width: max-content;
animation: marquee-scroll 2s linear infinite;
}
.cta-band__item {
margin-right: 24px;
flex-shrink: 0;
white-space: nowrap;
color: #fff7e6;
font-weight: 800;
font-size: clamp(24px, 7vw, 40px);
line-height: 1;
}
@keyframes marquee-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.cta-band__track {
animation: none;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./KineticCtaBand.module.scss";
export type KineticCtaBandProps = {
phrase?: string;
/** 한 바퀴 도는 시간(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function KineticCtaBand({
phrase = "Get started now —",
duration = 2000,
easing = "linear",
color = "#fff7e6",
}: KineticCtaBandProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 6개를 그대로 한 번 더 복제해 트랙 하나에 12개 — margin-right 간격, gap 은 쓰지 않는다 (이음새 점프 방지)
const items = Array.from({ length: 12 }, (_, i) => i);
return (
<div className={styles.stage} style={vars}>
<div className={styles.band}>
<div className={styles.track}>
{items.map((i) => (
<span key={i} className={styles.item} aria-hidden={i >= 6 ? true : undefined}>{phrase}</span>
))}
</div>
</div>
</div>
);
}07 · 마퀴 CTA — 측정 누적면적 8.303% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
이음새를 감추는 법
문구를 통째로 두 벌 이어 붙이고 정확히 절반(-50%)만큼 밀어야 이음새가 텔레포트처럼 매끄럽고, 간격은 gap이 아니라 항목마다 margin-right로 줘야 한 바퀴 돌 때마다 튀지 않습니다.
<div class="relative overflow-hidden rounded-tile bg-accent py-8">
<div class="flex w-max animate-marquee">
<span class="mr-12 flex-shrink-0 whitespace-nowrap font-display
text-5xl font-extrabold text-cream">지금 시작하기 —</span>
<span class="mr-12 flex-shrink-0 whitespace-nowrap font-display
text-5xl font-extrabold text-cream">지금 시작하기 —</span>
<span class="mr-12 flex-shrink-0 whitespace-nowrap font-display
text-5xl font-extrabold text-cream" aria-hidden="true">지금 시작하기 —</span>
</div>
</div>
08푸터
워드마크를 스치는 빛줄기
푸터 맨 아래 거대한 워드마크 위로 빛줄기가 대각선으로 한 번 스치고 지나갑니다.

<main class="stage">
<footer class="footer is-demo">
<nav class="footer__links">
<span class="footer__link">이용약관</span>
<span class="footer__link">개인정보처리방침</span>
</nav>
<p class="footer__wordmark">KINETIC</p>
</footer>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 빛 띠가 한 번 훑고 돌아오는 시간
$easing: $ease-out;
$color: $subject-blue; // 훑고 지나가는 빛 색
@include stage($stage-paper);
.footer {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-6;
text-align: center;
}
.footer__links {
display: flex;
gap: $sp-4;
}
.footer__link {
color: rgba($subject-ink, .72); // AA 4.5 (blog-design 글자 대비)
font-weight: 600;
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1;
}
// 거대 워드마크 위로 그라디언트가 한 번 대각선으로 훑는다 — background-clip: text 예외 (033 과 같은 계열)
.footer__wordmark {
margin: 0;
font-weight: 800;
font-size: clamp(36px, 12vw, 80px);
line-height: 1;
background: linear-gradient(100deg, $subject-ink 40%, $color 50%, $subject-ink 60%);
background-size: 250% 100%;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
animation: sheen-sweep $duration $easing infinite;
}
@keyframes sheen-sweep {
0% { background-position: 200% 0; }
55%, 100% { background-position: -50% 0; }
}
@media (prefers-reduced-motion: reduce) {
.footer__wordmark { animation: none; background-position: -50% 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
text-align: center;
}
.footer__links {
display: flex;
gap: 16px;
}
.footer__link {
color: rgba(23, 20, 26, 0.72);
font-weight: 600;
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1;
}
.footer__wordmark {
margin: 0;
font-weight: 800;
font-size: clamp(36px, 12vw, 80px);
line-height: 1;
background: linear-gradient(100deg, #17141a 40%, #2f6df6 50%, #17141a 60%);
background-size: 250% 100%;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
animation: sheen-sweep 2s ease-out infinite;
}
@keyframes sheen-sweep {
0% {
background-position: 200% 0;
}
55%, 100% {
background-position: -50% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.footer__wordmark {
animation: none;
background-position: -50% 0;
}
}import type { CSSProperties } from "react";
import styles from "./KineticFooter.module.scss";
export type KineticFooterProps = {
link1?: string;
link2?: string;
wordmark?: string;
/** 빛 띠가 한 번 훑고 돌아오는 시간(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function KineticFooter({
link1 = "Terms",
link2 = "Privacy",
wordmark = "KINETIC",
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: KineticFooterProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<footer className={styles.stage} style={vars}>
<nav className={styles.links}>
<span className={styles.link}>{link1}</span>
<span className={styles.link}>{link2}</span>
</nav>
<p className={styles.wordmark}>{wordmark}</p>
</footer>
);
}08 · 거대 워드마크 푸터 — 측정 누적면적 4.284% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
오래 쉬게 짠 시간표
background-clip: text로 그라디언트를 글자 모양 안에만 가두고, 한 번 스친 뒤에는 다시 정지 상태로 오래 쉬도록 시간표를 짰습니다.
<footer class="py-14">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="rounded-tile bg-ink/[.04] p-5">
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-ink/50">
제품
</p>
<p class="text-sm font-semibold">기능</p>
</div>
<div class="rounded-tile bg-ink/[.04] p-5">
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-ink/50">
회사
</p>
<p class="text-sm font-semibold">이용약관</p>
</div>
</div>
<p class="mt-10 text-center font-display text-7xl font-extrabold
text-ink/[.06] sm:text-8xl">MOTIF</p>
</footer>
09버튼·입력
가운데서 자라는 밑줄
링크형 버튼 아래 밑줄이 가운데서부터 좌우로 자랐다가 다시 사그라듭니다.

<main class="stage">
<div class="link-group">
<button class="link-btn is-demo" type="button">
<span class="link-btn__label">자세히 보기</span>
</button>
<input class="input-static" type="text" placeholder="이메일 주소" readonly>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 밑줄이 가운데서 자랐다 사그라드는 주기
$easing: $ease-spring;
$color: $subject-blue; // 밑줄·포커스 링 색
@include stage($stage-ink);
.link-group {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: $sp-6;
}
.link-btn {
position: relative;
border: 0;
background: none;
padding: $sp-2 0;
cursor: pointer;
color: $subject-cream;
}
.link-btn__label {
font-weight: 800;
font-size: clamp(18px, 5vw, 26px);
line-height: 1;
}
// 밑줄이 가운데(transform-origin: center)에서 시작해 양쪽으로 자랐다 사그라든다
.link-btn::after {
content: '';
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: $color;
transform-origin: center;
transform: scaleX(0);
}
.link-btn.is-demo::after {
animation: underline-pulse $duration $easing infinite;
}
@keyframes underline-pulse {
0%, 10% { transform: scaleX(0); }
50% { transform: scaleX(1); }
90%, 100% { transform: scaleX(0); }
}
.link-btn:focus-visible {
outline: 2px solid $color;
outline-offset: $sp-1;
}
// 버튼 라벨 글자 낱개 — index.html 말미 스크립트가 텍스트를 이 span 으로 쪼개고
// 결정론 해시로 --nx0·--ny0·--nr0(출발점) · --wx·--wy·--wr(힘장 경유점) 을 심는다. (형태2차 한 겹, 077 품질기준선)
.char {
display: inline-block;
animation: char-flow $duration $easing infinite;
}
.char--space { width: .28em; }
@keyframes char-flow {
0%, 4% { transform: translate(var(--nx0, 0), var(--ny0, 16px)) rotate(var(--nr0, 0deg)) scale(.55); opacity: 0; }
16% { transform: translate(var(--wx, 0), var(--wy, 4px)) rotate(var(--wr, 0deg)) scale(1.08); opacity: 1; }
30%, 78% { transform: translate(0, 0) rotate(0deg) scale(1); opacity: 1; }
96%, 100% { transform: translate(var(--nx0, 0), var(--ny0, 16px)) rotate(var(--nr0, 0deg)) scale(.55); opacity: 0; }
}
.input-static {
border: 1px solid rgba($subject-cream, .3);
border-radius: $r-control;
background: transparent;
color: $subject-cream;
padding: $sp-2 $sp-3;
font-weight: 500;
font-size: clamp(12px, 3vw, 14px);
line-height: 1;
}
.input-static::placeholder {
color: rgba($subject-cream, .5);
}
@media (prefers-reduced-motion: reduce) {
.link-btn.is-demo::after { animation: none; transform: scaleX(0); }
.char { animation: none; transform: none; opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.link-group {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 24px;
}
.link-btn {
position: relative;
border: 0;
background: none;
padding: 8px 0;
cursor: pointer;
color: #fff7e6;
}
.link-btn__label {
font-weight: 800;
font-size: clamp(18px, 5vw, 26px);
line-height: 1;
}
.link-btn::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: #2f6df6;
transform-origin: center;
transform: scaleX(0);
}
.link-btn.is-demo::after {
animation: underline-pulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes underline-pulse {
0%, 10% {
transform: scaleX(0);
}
50% {
transform: scaleX(1);
}
90%, 100% {
transform: scaleX(0);
}
}
.link-btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
}
.char {
display: inline-block;
animation: char-flow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.char--space {
width: 0.28em;
}
@keyframes char-flow {
0%, 4% {
transform: translate(var(--nx0, 0), var(--ny0, 16px)) rotate(var(--nr0, 0deg)) scale(0.55);
opacity: 0;
}
16% {
transform: translate(var(--wx, 0), var(--wy, 4px)) rotate(var(--wr, 0deg)) scale(1.08);
opacity: 1;
}
30%, 78% {
transform: translate(0, 0) rotate(0deg) scale(1);
opacity: 1;
}
96%, 100% {
transform: translate(var(--nx0, 0), var(--ny0, 16px)) rotate(var(--nr0, 0deg)) scale(0.55);
opacity: 0;
}
}
.input-static {
border: 1px solid rgba(255, 247, 230, 0.3);
border-radius: 12px;
background: transparent;
color: #fff7e6;
padding: 8px 12px;
font-weight: 500;
font-size: clamp(12px, 3vw, 14px);
line-height: 1;
}
.input-static::placeholder {
color: rgba(255, 247, 230, 0.5);
}
@media (prefers-reduced-motion: reduce) {
.link-btn.is-demo::after {
animation: none;
transform: scaleX(0);
}
.char {
animation: none;
transform: none;
opacity: 1;
}
}// 버튼 라벨 글자별 노이즈 오프셋 + 벡터필드 진입 (형태2차) — 라벨을 span 으로 쪼개고
// 결정론 해시로 출발점/경유점 변수만 심는다. 프레임은 style.scss 의 char-flow 키프레임이 만든다.
// 기존 underline-pulse 와 겹쳐 — 글자가 흩어졌다 힘장을 따라 자리 잡고, 그 아래 밑줄이 양쪽으로 자란다.
(function () {
function hash(n) { // 정수 → 0..1 결정론 난수 (sin 해시)
var s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
document.querySelectorAll('.link-btn .link-btn__label').forEach(function (label) {
var text = label.textContent;
label.textContent = '';
for (var c = 0; c < text.length; c++) {
var ch = document.createElement('span');
if (text[c] === ' ') { ch.className = 'char char--space'; }
else {
ch.className = 'char';
ch.textContent = text[c];
var s = c * 13 + 5;
var h1 = hash(s), h2 = hash(s + 1), h3 = hash(s + 2), h4 = hash(s + 3);
var ang = h1 * Math.PI * 2;
var rad = 12 + h2 * 22;
var x0 = Math.cos(ang) * rad, y0 = 16 + h3 * 14;
var rot0 = (h4 - .5) * 30;
var wx = x0 - 0.9 * y0 * 0.55, wy = y0 + 0.9 * x0 * 0.55;
var wr = rot0 * 0.4;
ch.style.setProperty('--nx0', x0.toFixed(1) + 'px');
ch.style.setProperty('--ny0', y0.toFixed(1) + 'px');
ch.style.setProperty('--nr0', rot0.toFixed(1) + 'deg');
ch.style.setProperty('--wx', (wx * .6).toFixed(1) + 'px');
ch.style.setProperty('--wy', (wy * .18 + 3).toFixed(1) + 'px');
ch.style.setProperty('--wr', wr.toFixed(1) + 'deg');
ch.style.animationDelay = (-0.045 * c).toFixed(3) + 's';
}
label.appendChild(ch);
}
});
})();import type { CSSProperties } from "react";
import styles from "./KineticButtonsInputs.module.scss";
export type KineticButtonsInputsProps = {
label?: string;
placeholder?: string;
/** 밑줄이 가운데서 자랐다 사그라드는 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CharVars = CSSProperties & {
"--nx0"?: string; "--ny0"?: string; "--nr0"?: string;
"--wx"?: string; "--wy"?: string; "--wr"?: string;
};
/** 정수 → 0..1 결정론 난수 (sin 해시 — vanilla 09 와 동일 계열) */
function hash(n: number): number {
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
/** 한 글자 = 출발점(노이즈) + 힘장 경유점 변수 묶음. vanilla index.html 스크립트와 같은 식. */
function charVars(c: number): CharVars {
const s = c * 13 + 5;
const h1 = hash(s), h2 = hash(s + 1), h3 = hash(s + 2), h4 = hash(s + 3);
const ang = h1 * Math.PI * 2;
const rad = 12 + h2 * 22;
const x0 = Math.cos(ang) * rad, y0 = 16 + h3 * 14;
const rot0 = (h4 - .5) * 30;
// 회전 힘장 v(p) = ω × p 를 출발점에서 dt 적분 — v = ω*(-y, x), dt=0.55, ω=0.9
const wx = x0 - 0.9 * y0 * 0.55, wy = y0 + 0.9 * x0 * 0.55;
const wr = rot0 * 0.4;
return {
"--nx0": `${x0.toFixed(1)}px`,
"--ny0": `${y0.toFixed(1)}px`,
"--nr0": `${rot0.toFixed(1)}deg`,
"--wx": `${(wx * .6).toFixed(1)}px`,
"--wy": `${(wy * .18 + 3).toFixed(1)}px`,
"--wr": `${wr.toFixed(1)}deg`,
animationDelay: `${(-0.045 * c).toFixed(3)}s`,
};
}
export default function KineticButtonsInputs({
label = "Learn more",
placeholder = "Email address",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: KineticButtonsInputsProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.group}>
<button type="button" className={styles.linkBtn}>
<span className={styles.label}>
{label.split("").map((ch, c) =>
ch === " " ? (
<span key={c} className={styles.charSpace} />
) : (
<span key={c} className={styles.char} style={charVars(c)}>{ch}</span>
)
)}
</span>
<span className={styles.underline} aria-hidden="true" />
</button>
<input className={styles.input} type="text" placeholder={placeholder} readOnly />
</div>
</div>
);
}09 · 밑줄 애니메이션 링크 버튼 — 측정 누적면적 2.883% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
포커스는 따로 표시한다
마우스로 호버하지 않고 키보드로 포커스를 옮겨도 별도의 테두리가 항상 따로 나타나, 밑줄 애니메이션 하나에만 기대지 않고도 지금 어디에 있는지 알 수 있습니다.
<div class="flex flex-col items-center gap-6 rounded-tile bg-ink p-10
sm:flex-row sm:justify-center">
<button class="relative border-0 bg-transparent font-display text-2xl
font-extrabold text-cream after:absolute after:-bottom-1
after:left-0 after:right-0 after:h-[2px] after:bg-accent">
자세히 보기
</button>
<label class="relative w-full max-w-xs rounded-[12px] bg-cream/5">
<input class="w-full rounded-[12px] border border-cream/30 bg-transparent
px-4 py-3 text-sm text-cream outline-none" type="email"
placeholder="이메일 주소" readonly>
</label>
</div>
tailwind.config 커스텀
설정 파일 하나를 공유한다
지금까지 열어 본 아홉 조각과 맨 위 원페이지는 모두 이 설정 파일 하나를 공유해서 씁니다.
한 곳만 바꾸면 된다
색·글꼴·반경·그림자 값을 여기 한 곳만 바꾸면 아홉 조각 전체의 톤이 동시에 맞춰집니다.
theme: {
extend: {
colors: {
cream: "#fff7e6",
ink: "#17141a",
accent: "#2f6df6",
line: "rgba(23,20,26,.08)",
},
fontFamily: {
display: ["'Space Grotesk'", "system-ui", "sans-serif"],
body: ["'Inter'", "system-ui", "sans-serif"],
},
borderRadius: { tile: "20px", pill: "999px" },
boxShadow: {
bento: "0 14px 24px rgba(23,20,26,.10)",
"bento-hover": "0 20px 32px rgba(23,20,26,.16)",
},
spacing: { gutter: "14px" },
},
},
바이브코딩 프롬프트
몇 줄만 던져도 된다
위 설정을 프로젝트에 붙여 넣었다면, Claude Code나 Cursor에게는 아래 몇 줄만 던져도 충분히 알아듣습니다. 지시문 전체는 줄이지 않은 원본 그대로 zip의 prompt.md에 들어 있습니다.
배경은 cream(#fff7e6) 하나, 카드는 순백이 아니라 ink(#17141a) 배경 위에 cream 글자로 만든다.
제목과 큰 숫자에는 항상 font-display(Space Grotesk)를 쓰고, 본문 글자에는 font-body(Inter)를 쓴다.
강조가 필요한 곳(버튼·추천 배지·CTA 띠)에만 accent(#2f6df6)를 쓰고 두 번째 강조색을 넣지 않는다.
반경은 rounded-tile·rounded-pill 두 값만 쓴다.
붙여 넣어 얻은 화면
위 지시문을 그대로 page/index.html에 붙여 넣어 얻은 화면을 1280px 너비 기준으로 통째로 캡처해 뒀습니다.

어디서 깨지는가 — 함정
theme() 가 조용히 무시된다
이 스타일에서 가장 흔한 함정은 배경·글자색을 theme() 함수로 <style> 태그 안에 직접 지정하는 것입니다. Tailwind Play CDN은 컴파일된 유틸리티 클래스(bg-cream 같은) 안의 theme() 값은 정상 처리하지만, <style> 태그 안에 직접 쓴 background: theme('colors.cream')는 조용히 무시하고 브라우저 기본값(투명 배경·검정 글자)으로 되돌아갑니다. 이 프로젝트도 처음엔 <body> 배경·글자색과 h2 글꼴까지 전부 <style> 태그의 theme()로 지정했다가, 실제로 렌더해 보니 배경이 투명하게 비치고 글자가 순검정으로 나와 크림·잉크 톤이 하나도 안 보이는 상태로 남을 뻔했습니다. <body class="bg-cream text-ink font-body">처럼 컴파일되는 유틸리티 클래스로 바꾸고, h2마다 font-display 클래스를 직접 달고 나서야 톤이 제대로 적용됐습니다. 글자를 잘게 쪼개 움직이는 이 방식이 통째로 막히는 곳이 메일함인데, 애니메이션을 못 쓰는 환경에서 어떻게 인상을 남기는지는 뉴스레터 디자인 템플릿이 보여 줍니다. 두 번째 함정은 02번 워드마크입니다. 스크롤하면 줄어드는 스티키 헤더처럼 보이지만 실제로는 스크롤과 무관한 반복 데모라, 자간까지 함께 줄어들 거라 기대하고 코드를 그대로 옮기면 자간은 그대로인 채 크기만 바뀌어 의아할 수 있습니다. 아홉 조각과 원페이지 소스 전체를 압축한 zip 파일은 잠겨 있고, 여는 암호는 y9my63zg이며 이 아래 버튼을 눌러 내려받으실 수 있습니다.
숫자가 깜빡이는 이유
세 번째 함정은 03번·05번의 숫자 애니메이션입니다. @property --num으로 정수 타입을 선언하지 않으면 브라우저가 0.5, 1.7 같은 소수 중간값으로 보간해 숫자가 깜빡이며 지나갑니다. syntax: '<integer>'를 반드시 선언해야 자릿수가 깔끔하게 하나씩 올라갑니다.
접근성
동작 줄이기에서는 정지 상태로
아홉 개 데모 전부 MDN prefers-reduced-motion 문서가 정의한 미디어 쿼리로 감쌌고, 동작 줄이기를 켠 방문자에게는 단어가 튀어 오르거나 숫자가 굴러가는 대신 정지된 최종 상태만 보입니다.
숫자와 마퀴의 처리
03번·05번처럼 counter()로 그린 숫자는 스크린리더가 읽지 못해 실제 값을 시각적으로만 숨긴 텍스트로 항상 함께 두었고, 07번 마퀴의 복제된 문구 절반은 aria-hidden으로 감춰 같은 말을 두 번 읽지 않게 했습니다.
포커스는 항상 보이게
09번은 밑줄 애니메이션과 별개로 :focus-visible 테두리를 항상 켜 둬, 움직임이 꺼진 환경에서도 지금 포커스가 어디 있는지 알 수 있습니다.
다른 유행 스타일도 궁금하시면 디자인 트렌드 카테고리에 더 모아 뒀고, 이 사이트를 운영하는 사람이 누구인지는 소개 페이지에서 확인하실 수 있습니다.
FAQ
MOTIF·KINETIC 같은 이름을 실제 서비스에 그대로 써도 되나요?
네, 문제없습니다. 이 편의 부품은 특정 실제 브랜드를 복제한 것이 아니라 핀터레스트에서 흔히 보이는 키네틱 타이포 스타일을 참고해 만든 가상의 이름(MOTIF)과 라벨(KINETIC)이라, 여러분의 서비스 이름으로 그대로 바꿔 써도 됩니다.
타이포그래피 템플릿의 폰트를 Space Grotesk·Inter 말고 다른 걸로 바꿔도 되나요?
가능합니다. tailwind.config.js의 fontFamily.display·fontFamily.body 두 값만 바꾸면 아홉 개 부품 전부에 새 글꼴이 한 번에 적용됩니다. 다만 큰 숫자·헤드라인처럼 굵고 눈에 띄는 자리(display)와 본문(body)의 역할 구분은 그대로 유지하는 것이 이 스타일의 핵심입니다.
Tailwind 없이 순수 CSS만으로도 이 부품들을 쓸 수 있나요?
가능합니다. zip에 든 tokens.css가 tailwind.config.js와 같은 값을 CSS 변수(--color-cream, --radius-tile 등)로 옮겨 담고 있어, Tailwind 클래스 대신 var(--radius-tile)처럼 직접 참조하면 색·반경·그림자를 동일하게 맞출 수 있습니다. 움직이는 글자가 오히려 방해가 되는 제품 소개라면 신뢰감 주는 SaaS 랜딩 키트의 차분한 배열이 더 맞습니다.









