I Took Kinetic Typography Apart in CSS
Kinetic typography is a design style where the headline scales with the viewport and words rise into place one after another.
Scroll the assembled one-page mock below before lifting any single piece out of it. Watch for the headline rising word by word, the stat card counting up to its real value, and the sheen that crosses the wordmark once at the very bottom.

The nine sections below are the same pieces cut apart into a typography hero section set you can open one at a time. They're ordered the way a visitor actually meets them on a real page — first glance, a skim past the features, a check on the price, a read of who else trusts it, a nudge toward acting, and finally the footer. Setting the mood with dark ground and glow instead of moving type is what the dark SaaS linear design system does, and turning the same screen soft and puffy gives the claymorphism landing kit.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Viewport-filling headline
- 02 Oversized wordmark nav
- 03 Oversized-number cards
- 04 One line per feature, big numbers
- 05 Price digits as the hero
- 06 Oversized quote
- 07 Marquee CTA
- 08 Oversized wordmark footer
- 09 Underline-animated link buttons
01Hero
Kinetic typography css, word by word
Three headline words climb up from behind a hidden edge one after another instead of appearing all at once.

<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">Thought's</span></span>
<span class="word"><span class="word__inner">speed —</span></span>
<span class="word"><span class="word__inner">we build.</span></span>
</h1>
<button class="hero__cta" type="button">Get started</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 가 we build..
// 원리: 각 글자의 (글자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 · Viewport-filling headline — Measured area 12.381% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Type that resizes itself
The whole headline resizes itself with clamp() so it never overflows a narrow screen or looks tiny on a wide one.
<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">
Built at the speed<br>we build.
</h1>
<button class="rounded-pill bg-ink px-8 py-4 text-base font-bold text-cream">
Get started
</button>
</section>
02Navigation
A wordmark that shrinks on a loop
The wordmark on the left of the nav bar shrinks and grows on its own, in a loop.

<main class="stage">
<nav class="navbar is-demo">
<span class="navbar__brand">KINETIC</span>
<div class="navbar__right">
<span class="navbar__link">About</span>
<span class="navbar__link">Pricing</span>
<button class="navbar__cta" type="button">Get started</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 키프레임이 we build..
// 기존 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 · Oversized wordmark nav — Measured area 1.86% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Not actually tied to scroll
It's not actually tied to scroll — it's a standalone demo standing in for the shrinking-logo effect a real sticky header gives you, and the only thing that changes is size; letter-spacing stays put.
<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">About</span>
<span class="text-sm font-semibold text-cream/70">Features</span>
<span class="text-sm font-semibold text-cream/70">Pricing</span>
</div>
<button class="rounded-pill bg-accent px-5 py-2 text-sm font-bold text-cream">
Get started
</button>
</nav>
</header>
03Card grid
Counting up digit by digit
The number inside each card counts up from zero to its real value, digit by digit.

<main class="stage">
<div class="card is-demo">
<span class="card__num" aria-hidden="true"></span>
<span class="card__sr">248 projects</span>
<span class="card__label">Projects</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 · Oversized-number cards — Measured area 9.127% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Hidden text for screen readers
Because content generated by CSS counter() is invisible to screen readers, the finished value ships a second time as hidden text ("248 projects") that stays readable no matter how the animation is set.
<section class="py-14">
<h2 class="mb-8 font-display text-3xl font-extrabold">The numbers</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">Projects</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">Uptime</p>
</div>
</div>
</section>
04Feature list
Rows nudging in sequence
Three rows nudge to the right one after another, top to bottom, and the underline beneath each label grows on the same beat.

<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">Real-time collaboration</span>
</li>
<li class="feature-list__row">
<span class="feature-list__index">02</span>
<span class="feature-list__label">Auto-deploy</span>
</li>
<li class="feature-list__row">
<span class="feature-list__index">03</span>
<span class="feature-list__label">Team dashboard</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 키프레임이 we build..
// 기존 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 · One line per feature, big numbers — Measured area 7.512% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The number is decoration
The bold number out front reads like a code editor's line number — pure decoration, since the real feature name is always plain text right beside it.
<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">Real-time collaboration</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">Auto-deploy</span>
</li>
</ul>
05Pricing
Digits rolling between two prices
The price digits roll back and forth between a monthly figure ($19) and a yearly one ($190) on their own, over and over.

<main class="stage">
<div class="price is-demo">
<p class="price__plan">Pro plan</p>
<div class="price__amount">
<span class="price__currency">₩</span>
<span class="price__num" aria-hidden="true"></span>
<span class="price__sr">$19/mo · $190/yr</span>
</div>
<p class="price__suffix">/ mo</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 · Price digits as the hero — Measured area 4.107% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Wiring a real toggle
It isn't wired to a toggle button — the loop runs by itself in this demo, and wiring a real monthly/yearly switch to it is a one-line addition when you drop it into a project.
<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">Free</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">Recommended</span>
<p class="font-bold text-cream/70">Pro plan</p>
<p class="mt-2 font-display text-5xl font-extrabold tabular-nums
text-cream">
$19<span class="text-lg font-semibold text-cream/60"> / mo</span>
</p>
</div>
</div>
06Testimonials
A curtain drawn left to right
The quote reveals itself left to right in one pass, like a curtain drawing back.

<main class="stage">
<figure class="quote is-demo">
<p class="quote__text">“Speed changed how our team works.”</p>
<figcaption class="quote__cite">Park Ji-hoon · Growth Lead</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 키프레임이 we build..
// 기존 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 · Oversized quote — Measured area 7.105% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why clip-path, not a mask
It uses clip-path rather than the vertical mask that section 01's headline relies on.
<section class="py-14">
<h2 class="mb-8 font-display text-3xl font-extrabold">What people say</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">
"Speed changed how our team works."
</p>
<figcaption class="mt-4 text-sm text-ink/60">Park Ji-hoon, Growth Lead</figcaption>
</figure>
</section>
07CTA band
A seamless loop across the band
The phrase loops across the full-width band without a visible seam.

<main class="stage">
<div class="cta-band">
<div class="cta-band__track">
<span class="cta-band__item">Get started now —</span>
<span class="cta-band__item">Get started now —</span>
<span class="cta-band__item">Get started now —</span>
<span class="cta-band__item">Get started now —</span>
<span class="cta-band__item">Get started now —</span>
<span class="cta-band__item">Get started now —</span>
<span class="cta-band__item" aria-hidden="true">Get started now —</span>
<span class="cta-band__item" aria-hidden="true">Get started now —</span>
<span class="cta-band__item" aria-hidden="true">Get started now —</span>
<span class="cta-band__item" aria-hidden="true">Get started now —</span>
<span class="cta-band__item" aria-hidden="true">Get started now —</span>
<span class="cta-band__item" aria-hidden="true">Get started now —</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 · Marquee CTA — Measured area 8.303% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why margin-right beats gap
Getting there means duplicating the whole line once, chaining both copies in a single track, and shifting exactly -50% — spacing has to come from margin-right on each item rather than a flex gap, or the loop skips a few pixels every time it comes back around.
<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">Get started now —</span>
<span class="mr-12 flex-shrink-0 whitespace-nowrap font-display
text-5xl font-extrabold text-cream">Get started now —</span>
<span class="mr-12 flex-shrink-0 whitespace-nowrap font-display
text-5xl font-extrabold text-cream" aria-hidden="true">Get started now —</span>
</div>
</div>
08Footer
A sheen that crosses once
A diagonal sheen crosses the oversized wordmark at the very bottom of the footer exactly once.

<main class="stage">
<footer class="footer is-demo">
<nav class="footer__links">
<span class="footer__link">Terms</span>
<span class="footer__link">Privacy Policy</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 · Oversized wordmark footer — Measured area 4.284% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
Holding still between passes
background-clip: text traps the gradient inside the letterforms, and once the sweep finishes it holds still for most of the loop before repeating.
<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">
Product
</p>
<p class="text-sm font-semibold">Features</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">
Company
</p>
<p class="text-sm font-semibold">Terms</p>
</div>
</div>
<p class="mt-10 text-center font-display text-7xl font-extrabold
text-ink/[.06] sm:text-8xl">MOTIF</p>
</footer>
09Buttons & inputs
An underline growing from center
The underline beneath a link-style button grows outward from its own center on a loop rather than in response to a real hover trigger, and a separate focus outline shows up whenever a keyboard user tabs in.

<main class="stage">
<div class="link-group">
<button class="link-btn is-demo" type="button">
<span class="link-btn__label">Learn more</span>
</button>
<input class="input-static" type="text" placeholder="Email address" 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 키프레임이 we build..
// 기존 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 · Underline-animated link buttons — Measured area 2.883% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
A focus outline of its own
The button never relies only on the underline to say where the focus currently sits.
<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">
Learn more
</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="Email address" readonly>
</label>
</div>
Custom tailwind.config
One config behind every section
Every section above, plus the one-page mock, pulls from this single config.
Move a value, move the page
Move a color, radius, or shadow value here and the whole page follows without touching a single section.
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" },
},
},
Vibe-coding prompt
A handful of rules is enough
Drop the config above into a project first; handing a handful of rules to Claude Code or Cursor is enough to keep the whole page on-brand. The unabridged instruction ships as-is in the zip's prompt.md.
Background is cream (#fff7e6) only. Cards sit on ink (#17141a) with cream text on top,
never on pure white or gray.
Headlines and big numbers always use font-display (Space Grotesk); body copy uses
font-body (Inter). Don't mix the two.
Reserve accent (#2f6df6) for buttons, the recommended badge, and the CTA band —
never add a second accent color.
Radius comes from only two values: rounded-tile and rounded-pill.
What the prompt produced
Running that prompt against page/index.html produced the screenshot below, a full-page capture at a 1280px viewport.

Where it breaks (the trap)
Why theme() falls back to defaults
The most common way this style breaks is by setting color and background through a theme() call written inside a <style> tag. The Tailwind Play CDN resolves theme() fine inside compiled utility classes like bg-cream, but it silently ignores the same call when it's written directly inside <style>, and the page falls back to the browser's own defaults — a transparent background, black text. This project actually shipped that bug on the first pass: <body>'s background and text color, plus the font on every <h2>, were all set through theme() in a <style> block, and rendering it for real showed a transparent body (computed background rgba(0,0,0,0)) with plain black text instead of ink, and headings losing the display font entirely. Switching <body> to compiled utility classes (bg-cream text-ink font-body) and adding font-display directly on each <h2> is what actually fixed it. The place where splitting type into moving pieces is blocked outright is the inbox, and how to leave an impression without animation is what the marketing email templates demonstrate. The second trap sits in section 02 — its wordmark looks like it's tied to scroll, but the loop runs on a timer with no scroll listener at all, and only size changes, not letter-spacing, so if you copy the CSS expecting a tracking animation to come along with it, you'll come up short. The zip bundling all nine sections plus the one-page source is locked, and the password is y9my63zg — unlock it with the button just below.
Why the digits flicker
A third trap sits in sections 03 and 05. Skip declaring @property --num with syntax: '<integer>' and the browser interpolates through fractional values like 0.5 or 1.7 on the way up, so the digits flicker instead of landing cleanly on each whole number.
Accessibility
What reduced motion settles to
All nine demos sit inside the media query MDN documents for prefers-reduced-motion, so a visitor who's turned on reduced motion sees each one settled at its end state instead of rising, counting, or sweeping.
Counters, hidden text, and marquees
The count-up numbers in sections 03 and 05 ship a hidden, always-readable copy of the final value because CSS counter() content is invisible to screen readers, and half of the duplicated copies in section 07's marquee are marked aria-hidden so the same words aren't read out twice.
Focus that never disappears
Section 09 keeps a :focus-visible outline running independently of the underline animation, so keyboard focus stays visible even with motion turned off.
More trend write-ups like this one live in the design trends category, and what this project is (and isn't) is explained on the about page.
FAQ
Can I reuse the MOTIF and KINETIC names from these kinetic typography examples in a real product?
Yes. Neither name copies an existing brand — they're fictional placeholders invented for this write-up, inspired by the general look of kinetic-typography landing pages seen on Pinterest, so swapping in your own product name is expected, not a trademark risk.
Can I swap out Space Grotesk and Inter for other fonts?
Yes. Changing fontFamily.display and fontFamily.body in tailwind.config.js updates every section at once. Keep the split itself, though — a bold display face for headlines and big numbers, paired with a quieter body face for everything else, is what makes the oversized type read as intentional rather than just loud.
Does any of this work without Tailwind, in plain CSS?
Yes. The zip includes tokens.css, which carries the same values as tailwind.config.js written out as CSS custom properties (--color-cream, --radius-tile, and so on), so referencing var(--radius-tile) directly keeps every corner and color in sync without a single Tailwind class. When moving letters would get in the way of explaining a product, the calmer arrangement of the trustworthy SaaS landing kit fits better.









