キネティックタイポグラフィをコードで分解してみた
キネティックタイポグラフィとは、見出しの文字サイズが画面幅に合わせて変わり、単語が順番に浮かび上がり、数字がオドメーターのように上がっていくデザイン手法のことです。最近のランディングページはどれもファーストビューがこれなので、実際にコードを分解して9つのパーツに切り分けてみました。
まずは9つの部品をそのままつないだ一枚のランディングページを、上から下までスクロールしてみてください。見出しが単語ごとに浮き上がる様子、カードの数字が実際の値まで駆け上がる様子、いちばん下のワードマークを光が一度だけ斜めに横切る様子が見どころです。

下の格子にある9つは、上の画面から切り出した同じ部品です。並び順は、訪問者が実際のランディングページをたどる流れ(最初に目が止まる場所、機能をざっと見る場所、価格を確かめる場所、信頼できるか判断する場所、行動を後押しされる場所、最後にたどり着くフッター)のとおりにしてあります。文字ではなく濃い背景とグローで空気を作る側はダークSaaSのリニアなデザインシステムが担い、同じ画面を柔らかい塊へ変えるとクレイモーフィズムのLPキットになります。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ビューポートを埋める見出し
- 02 巨大ワードマークナビ
- 03 巨大数字カード
- 04 1行1機能・大きな番号
- 05 価格数字が主役
- 06 超大型引用文
- 07 マーキーCTA
- 08 巨大ワードマークフッター
- 09 下線アニメーションリンクボタン
01ヒーロー
キネティックタイポグラフィとは何が動くのか
見出しの3つの単語が、隠れた枠の中から順番に下から上へ現れます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
画面幅に合わせて変わる
文字サイズ自体も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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
スクロールとは無関係
実際のスクロールと連動しているわけではなく、スクロールで縮む一般的なstickyヘッダーの見た目をループのデモで先に見せているだけで、変わるのは大きさだけ、字間は動きません。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
読み上げ用の隠しテキスト
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機能一覧
3行が順に寄って戻る
3行のリストが上から順に右へわずかに寄っては戻り、下の下線も同じ拍子で伸びては消えます。

<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 · 1行1機能・大きな番号 — 計測 累積面積 7.512% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
番号は装飾でしかない
左側の大きな番号はコードエディターの行番号のような装飾で、実際の機能名は常に文字として隣にあるため番号だけでは意味が途切れません。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
本物の切り替えを足す
ボタンを押して切り替わるトグルではなく最初から最後まで勝手に動くデモで、実際のサービスに組み込むときはこの場所に本物の月額・年額切り替えボタンを足せば済みます。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
gapではなくmargin-right
文言をまるごと2組つなげてトラック1本にし、ちょうど半分(-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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
大半は静止している
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
フォーカスは別枠で示す
マウスでホバーしなくてもキーボードでフォーカスを移すと別枠の:focus-visibleが必ず現れるため、下線アニメーションひとつだけに頼らずに今どこにいるかがわかります。
<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 カスタム
参照するのは設定ひとつ
ここまでの9つの部品と一枚のランディングページは、すべてこの設定ひとつだけを参照しています。
書き換えれば一度にそろう
色・書体・角丸・影の値をここで書き換えると、画面全体の色調が一度にそろって変わります。
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() が静かに無視される
このスタイルでいちばん多い落とし穴は、背景色や文字色を<style>タグの中でtheme()関数を使って直接指定することです。Tailwind Play CDNはbg-creamのようなコンパイル済みのユーティリティクラスの中のtheme()は正しく処理しますが、<style>タグの中に直書きしたbackground: theme('colors.cream')は静かに無視され、ブラウザ既定値(透明な背景・黒い文字)へ戻ってしまいます。このプロジェクトも最初は<body>の背景・文字色とh2の書体まで全部<style>タグのtheme()で指定していましたが、実際にレンダリングして確かめると、背景は透明(computed backgroundがrgba(0,0,0,0))、文字はink色ではなくただの黒、見出しの書体も反映されていない、という状態でした。<body class="bg-cream text-ink font-body">のようにコンパイルされるユーティリティクラスへ書き換え、h2一つひとつにfont-displayクラスを直接付けてはじめて正しく直りました。文字を細かく割って動かすこの方法がまるごと封じられる場所がメールの受信箱で、アニメーションを使えない環境でどう印象を残すかはメルマガのデザインテンプレートが見せてくれます。二つ目の落とし穴は02のワードマークです。見た目はスクロールに連動しているようですが、実際はスクロール監視なしのループ再生なので、字間まで一緒に縮むと期待してコードをそのまま流用すると、大きさしか変わらず戸惑うかもしれません。9つの部品と一枚のランディングページのソースを丸ごと収めたzipには鍵がかかっていて、そのパスワードはy9my63zgで、本文の下にあるボタンから受け取れます。
数字がちらつく理由
三つ目の落とし穴は03・05の数字アニメーションです。@property --numでsyntax: '<integer>'を宣言しないと、ブラウザは0.5や1.7のような小数の途中値まで補間してしまい、数字がちらつきながら進みます。整数型を宣言してはじめて、桁がきれいにひとつずつ上がります。
アクセシビリティ
動きを減らすと最終状態だけ
9つのデモはすべてMDNのprefers-reduced-motionのページが定めるメディアクエリで包んであり、動作を減らす設定を有効にした方には単語が浮き上がったり数字が駆け上がったりする代わりに、静止した最終状態だけが見えます。
数字とマーキーの扱い
03・05の数字はcounter()で描いているためスクリーンリーダーが読み上げられず、実際の値を見た目だけ隠したテキストとして常に添えてあります。
フォーカスは常に見える
07のマーキーで複製した半分はaria-hiddenにして同じ文言を二度読み上げないようにし、09は下線アニメーションとは別に:focus-visibleの枠を常に有効にしてあるため、動きを止めた環境でもフォーカスの位置がわかります。
ほかの kinetic typography examples やトレンドが気になる方はデザイントレンドのカテゴリーにまとめてあり、このサイトを運営しているのが誰かはサイト紹介ページでご確認いただけます。
FAQ
キネティックタイポグラフィ 作り方で使ったMOTIFやKINETICの名前をそのまま使ってもいいですか?
はい、問題ありません。この記事の部品は特定の実在ブランドをコピーしたものではなく、ピンタレストでよく見かけるキネティックタイポグラフィの雰囲気を参考に考えた架空の名前(MOTIF)とラベル(KINETIC)ですので、ご自身のサービス名に置き換えていただいて構いません。
Space GroteskやInter以外の書体に変えてもいいですか?
可能です。tailwind.config.jsのfontFamily.displayとfontFamily.bodyの二つの値を変えるだけで、9つの部品すべてに新しい書体が一度に反映されます。ただし、太く目立つ見出し・大きな数字(display)と落ち着いた本文(body)という役割分担そのものは残しておくことをおすすめします。
Tailwindを使わず素のCSSだけでも使えますか?
使えます。zipに入っているtokens.cssがtailwind.config.jsと同じ値を--color-creamや--radius-tileのようなCSS変数として書き出しているので、Tailwindのクラスの代わりにvar(--radius-tile)と直接参照すれば、色や角丸をそのまま合わせられます。動く文字がかえって邪魔になる製品紹介なら、信頼感のあるSaaS向けLPキットの落ち着いた並びのほうが合います。









