04One line per feature, big numbers
Each feature row carries a big index number, and hovering slides both the number and text to the right together.
Best forFeature lists, service overview sections
grid 번호 정렬호버 translateX@keyframes underline-grow

<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 · I Took Kinetic Typography Apart in CSS
The post this demo lives in
I Took Kinetic Typography Apart in CSSKinetic typography is a design style where the headline scales with the viewport and words rise into place one after ano See all demos in this post


