09Timing value preview
Duration, delay, and iteration ride on sliders and the ball beside them restarts with those values. One animation shorthand line lands in the code box.
Best forTuning the speed of an entrance animation, deciding the stagger between elements
animation-durationanimation-delaygetAnimations

<main class="stage">
<div class="gen is-demo" id="gen">
<div class="gen__top">
<div class="gen__preview" aria-hidden="true">
<span class="gen__track-line"></span>
<span class="gen__ball"></span>
</div>
<div class="gen__grid">
<label class="gen__row">
<span class="gen__lab">Length</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="200" max="1200" step="100" value="600" data-var="--a-dur" aria-label="Animation length">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">Delay</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="400" step="50" value="0" data-var="--a-delay" aria-label="Start delay">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">Repeat</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="1" max="5" step="1" value="2" data-var="--a-iter" aria-label="Iteration count">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
</div>
</div>
<div class="gen__out">
<code class="gen__code"><span class="gen__prop">animation-duration:</span> <span class="gen__v gen__v--dur"></span>ms; <span class="gen__prop">animation-delay:</span> <span class="gen__v gen__v--delay"></span>ms; <span class="gen__prop">animation-iteration-count:</span> <span class="gen__v gen__v--iter"></span></code>
<button class="gen__copy" type="button" id="copy">Copy</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 타이밍 값 미리보기 — 길이·지연·반복 세 값이 공의 animation 낱개 속성을 그대로 움직인다.
// animation 단축이 아니라 낱개 속성을 쓰는 게 이 데모의 주제이기도 하다: 단축은 자리에 없는
// 하위 속성을 초기값(0s) 으로 리셋하므로, delay 를 따로 두는 순간 단축이 그걸 지운다.
// 미리보기(.is-demo)는 세 속성을 그대로 오가게 둔다 — 공의 duration 이 매 순간 바뀌는 것도
// 제너레이터가 보여 주려는 값의 효과 그 자체다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-ink;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(23, 20, 26, .14);
$track: rgba(23, 20, 26, .18);
$rail: 4px;
$thumb: 12px;
@property --a-dur { syntax: "<integer>"; inherits: true; initial-value: 600; }
@property --a-delay { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --a-iter { syntax: "<integer>"; inherits: true; initial-value: 2; }
@include stage($stage-yellow);
.gen {
min-width: 0; // stage grid item 의 자동 최소폭(min-content) 을 끊는다 — BD9
--a-dur: 600;
--a-delay: 0;
--a-iter: 2;
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.gen__top { display: flex; align-items: center; gap: $sp-3; }
// ── 미리보기 — 공이 트랙을 왕복한다. 세 속성이 그대로 반영된다
.gen__preview {
position: relative; flex: 0 0 auto;
box-sizing: border-box; width: 148px; height: 76px;
border-radius: $r-sm;
background: rgba(23, 20, 26, .06);
}
.gen__track-line {
position: absolute; left: 16px; right: 16px; top: 50%;
height: $rail; margin-top: -($rail * .5);
border-radius: $r-pill; background: rgba(23, 20, 26, .18);
}
.gen__ball {
position: absolute; left: 16px; top: 50%;
width: 20px; height: 20px; margin-top: -10px;
border-radius: 50%; background: $subject-blue;
// 낱개 속성 — 단축이 delay 를 리셋하는 함정을 피한다
animation-name: ballRun;
animation-duration: calc(var(--a-dur) * 1ms);
animation-delay: calc(var(--a-delay) * 1ms);
animation-iteration-count: var(--a-iter);
animation-timing-function: $easing;
animation-direction: alternate;
animation-fill-mode: both;
}
@keyframes ballRun { from { transform: translateX(0); } to { transform: translateX(96px); } }
.gen__grid { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: $sp-1; }
.gen__row { display: flex; align-items: center; gap: $sp-1; cursor: pointer; }
.gen__lab {
flex: 0 0 auto; width: 30px;
color: $subject-ink;
font-weight: 700; font-size: 10px; line-height: 1.4;
}
.gen__slot {
position: relative; flex: 1 1 auto; min-width: 0;
display: grid; align-items: center; height: 16px;
}
.gen__slot::before {
content: ""; grid-area: 1 / 1;
height: $rail; border-radius: $r-pill; background: $track;
}
.gen__rng {
grid-area: 1 / 1; appearance: none; -webkit-appearance: none;
width: 100%; min-width: 0; height: 16px; margin: 0; background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
.gen__thumb {
position: absolute; top: 50%; left: calc(var(--pct) * 1%);
width: $thumb; height: $thumb; margin-top: -($thumb * .5); margin-left: -($thumb * .5);
border-radius: 50%; background: $color;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb { --pct: calc((var(--a-dur) - 200) / 1000 * 100); }
.gen__row:nth-child(2) .gen__thumb { --pct: calc(var(--a-delay) / 400 * 100); }
.gen__row:nth-child(3) .gen__thumb { --pct: calc((var(--a-iter) - 1) / 4 * 100); }
.gen__out { display: flex; align-items: flex-start; gap: $sp-2; }
.gen__code {
flex: 1 1 auto; min-width: 0;
padding: $sp-2; border-radius: $r-sm;
background: $subject-ink; color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
.gen__prop { color: $subject-blue-light; }
.gen__v { font-weight: 700; }
.gen__v--dur::after { counter-reset: v var(--a-dur); content: counter(v); }
.gen__v--delay::after { counter-reset: v var(--a-delay); content: counter(v); }
.gen__v--iter::after { counter-reset: v var(--a-iter); content: counter(v); }
.gen__copy {
flex: 0 0 auto;
padding: $sp-2 $sp-3; border: 0; border-radius: $r-sm;
background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 — 세 속성이 오가는 동안 공은 계속 값대로 왕복한다
@keyframes aDur { 0%, 100% { --a-dur: 600; } 40% { --a-dur: 1100; } 75% { --a-dur: 300; } }
@keyframes aDelay { 0%, 100% { --a-delay: 0; } 50% { --a-delay: 200; } }
@keyframes aIter { 0%, 100% { --a-iter: 2; } 45% { --a-iter: 4; } 80% { --a-iter: 1; } }
.gen.is-demo {
animation-name: aDur, aDelay, aIter;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
// reduce 에서는 속성 순환과 공 모두 멈춘다 — 공은 시작 위치에 남긴다
@media (prefers-reduced-motion: reduce) {
.gen.is-demo { animation: none !important; }
.gen__ball { animation-name: none; }
}
@media (max-width: 340px) {
.gen { gap: $sp-2; padding: $sp-2; }
.gen__top { gap: $sp-2; }
.gen__preview { width: 116px; }
.gen__code { font-size: 9px; }
}@property --a-dur {
syntax: "<integer>";
inherits: true;
initial-value: 600;
}
@property --a-delay {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --a-iter {
syntax: "<integer>";
inherits: true;
initial-value: 2;
}
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);
}
.gen {
min-width: 0;
--a-dur: 600;
--a-delay: 0;
--a-iter: 2;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.gen__top {
display: flex;
align-items: center;
gap: 12px;
}
.gen__preview {
position: relative;
flex: 0 0 auto;
box-sizing: border-box;
width: 148px;
height: 76px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.06);
}
.gen__track-line {
position: absolute;
left: 16px;
right: 16px;
top: 50%;
height: 4px;
margin-top: -2px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.gen__ball {
position: absolute;
left: 16px;
top: 50%;
width: 20px;
height: 20px;
margin-top: -10px;
border-radius: 50%;
background: #2f6df6;
animation-name: ballRun;
animation-duration: calc(var(--a-dur) * 1ms);
animation-delay: calc(var(--a-delay) * 1ms);
animation-iteration-count: var(--a-iter);
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-direction: alternate;
animation-fill-mode: both;
}
@keyframes ballRun {
from {
transform: translateX(0);
}
to {
transform: translateX(96px);
}
}
.gen__grid {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
.gen__row {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.gen__lab {
flex: 0 0 auto;
width: 30px;
color: #17141a;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.gen__slot {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: grid;
align-items: center;
height: 16px;
}
.gen__slot::before {
content: "";
grid-area: 1/1;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.gen__rng {
grid-area: 1/1;
appearance: none;
-webkit-appearance: none;
width: 100%;
min-width: 0;
height: 16px;
margin: 0;
background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
border-radius: 999px;
}
.gen__thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * 1%);
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
border-radius: 50%;
background: #17141a;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb {
--pct: calc((var(--a-dur) - 200) / 1000 * 100);
}
.gen__row:nth-child(2) .gen__thumb {
--pct: calc(var(--a-delay) / 400 * 100);
}
.gen__row:nth-child(3) .gen__thumb {
--pct: calc((var(--a-iter) - 1) / 4 * 100);
}
.gen__out {
display: flex;
align-items: flex-start;
gap: 8px;
}
.gen__code {
flex: 1 1 auto;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.gen__prop {
color: #5b8cf8;
}
.gen__v {
font-weight: 700;
}
.gen__v--dur::after {
counter-reset: v var(--a-dur);
content: counter(v);
}
.gen__v--delay::after {
counter-reset: v var(--a-delay);
content: counter(v);
}
.gen__v--iter::after {
counter-reset: v var(--a-iter);
content: counter(v);
}
.gen__copy {
flex: 0 0 auto;
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@keyframes aDur {
0%, 100% {
--a-dur: 600;
}
40% {
--a-dur: 1100;
}
75% {
--a-dur: 300;
}
}
@keyframes aDelay {
0%, 100% {
--a-delay: 0;
}
50% {
--a-delay: 200;
}
}
@keyframes aIter {
0%, 100% {
--a-iter: 2;
}
45% {
--a-iter: 4;
}
80% {
--a-iter: 1;
}
}
.gen.is-demo {
animation-name: aDur, aDelay, aIter;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo {
animation: none !important;
}
.gen__ball {
animation-name: none;
}
}
@media (max-width: 340px) {
.gen {
gap: 8px;
padding: 8px;
}
.gen__top {
gap: 8px;
}
.gen__preview {
width: 116px;
}
.gen__code {
font-size: 9px;
}
}var gen = document.getElementById('gen');
var copyBtn = document.getElementById('copy');
var ranges = Array.prototype.slice.call(gen.querySelectorAll('.gen__rng'));
var ball = gen.querySelector('.gen__ball');
var COPY_LABEL = 'Copy';
var DONE_LABEL = 'Copied';
// Length·Delay·Repeat 세 값 — 미리보기 공의 animation 낱개 속성이 전부 여기서 나온다.
// 단축(animation:)은 쓰지 않는다: 단축은 delay 를 0 으로 리셋하는 함정이 있다(scss 주석 참조)
function readVar(name) {
return parseInt(getComputedStyle(gen).getPropertyValue(name), 10) || 0;
}
function timingText() {
return 'animation-duration: ' + readVar('--a-dur') + 'ms;\nanimation-delay: ' + readVar('--a-delay') + 'ms;\nanimation-iteration-count: ' + readVar('--a-iter') + ';';
}
// 값이 바뀌면 공의 애니메이션을 새로 띄운다 — getAnimations 으로 잡아 시계를 0 에 되돌린다
function restartBall() {
ball.getAnimations().forEach(function (a) {
a.cancel();
});
// cancel 뒤 같은 이름의 애니메이션이 다시 만들어진다 — CSS 시트가 그대로 있으므로
}
// 클립보드는 iframe·http 환경에서 막힐 수 있다 — 거부된 프로미스를 반드시 catch 로 받아 선택 방식으로 떨어진다
function fallbackCopy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
try { document.execCommand('copy'); } catch (err) { /* 여기까지 막히면 조용히 포기한다 */ }
document.body.removeChild(area);
}
ranges.forEach(function (input) {
input.addEventListener('input', function () {
gen.classList.remove('is-demo');
gen.style.setProperty(input.dataset.var, input.value);
restartBall();
});
input.addEventListener('pointerdown', function () { gen.classList.remove('is-demo'); });
});
copyBtn.addEventListener('click', function () {
gen.classList.remove('is-demo');
var text = timingText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
copyBtn.textContent = DONE_LABEL;
setTimeout(function () { copyBtn.textContent = COPY_LABEL; }, 1200);
});import { useState, useMemo } from 'react';
import styles from './TimingPreview.module.scss';
type Axis = { key: string; label: string; min: number; max: number; step: number };
// ambient 선언에 React 이벤트 제네릭이 없어 로컬 타입으로 받는다 (blog-demo TS7006 항)
type RangeEvt = { target: { value: string } };
const AXES: Axis[] = [
{ key: 'dur', label: '길이', min: 200, max: 1200, step: 100 },
{ key: 'delay', label: '지연', min: 0, max: 400, step: 50 },
{ key: 'iter', label: '반복', min: 1, max: 5, step: 1 }
];
export type TimingPreviewProps = {
duration?: string;
easing?: string;
color?: string;
};
export default function TimingPreview({ duration = '2s', easing = 'cubic-bezier(.2, 1.4, .4, 1)', color = '#17141a' }: TimingPreviewProps) {
const [values, setValues] = useState<Record<string, number>>({ dur: 600, delay: 0, iter: 2 });
const [live, setLive] = useState(true);
const codeText = useMemo(() => {
return `animation-duration: ${values.dur}ms;\nanimation-delay: ${values.delay}ms;\nanimation-iteration-count: ${values.iter};`;
}, [values]);
// 값이 바뀔 때마다 key 를 바꿔 애니메이션을 새로 띄운다 — CSS cancel 에 해당하는 React 관용
const animKey = useMemo(() => `${values.dur}-${values.delay}-${values.iter}`, [values]);
const style = {
'--dur-loop': duration,
'--ease-spring': easing,
'--accent': color,
'--a-dur': values.dur,
'--a-delay': values.delay,
'--a-iter': values.iter
} as Record<string, string | number>;
function copy() {
setLive(false);
try {
navigator.clipboard.writeText(codeText);
} catch (err) {
/* 클립보드가 막힌 환경에서는 조용히 넘어간다 */
}
}
return (
<div className={live ? `${styles.gen} ${styles.isDemo}` : styles.gen} style={style}>
<div className={styles.top}>
<div className={styles.preview} aria-hidden="true">
<span className={styles.trackLine}></span>
<span key={animKey} className={styles.ball}></span>
</div>
<div className={styles.grid}>
{AXES.map((axis) => (
<label className={styles.row} key={axis.key}>
<span className={styles.lab}>{axis.label}</span>
<span className={styles.slot}>
<input
className={styles.rng}
type="range"
min={axis.min}
max={axis.max}
step={axis.step}
value={values[axis.key]}
aria-label={axis.label}
onChange={(e: RangeEvt) => {
setLive(false);
setValues((v) => ({ ...v, [axis.key]: Number(e.target.value) }));
}}
/>
<span
className={styles.thumb}
aria-hidden="true"
style={{ left: `${((values[axis.key] - axis.min) / (axis.max - axis.min)) * 100}%` }}
/>
</span>
</label>
))}
</div>
</div>
<div className={styles.out}>
<code className={styles.code}>
<span className={styles.prop}>animation-duration:</span> {values.dur}ms; <span className={styles.prop}>animation-delay:</span> {values.delay}ms; <span className={styles.prop}>animation-iteration-count:</span> {values.iter}
</code>
<button className={styles.copy} type="button" onClick={copy}>복사</button>
</div>
</div>
);
}09 Timing value preview — Measured area 3.245% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Generator: 9 Sliders That Write the Code
The post this demo lives in
CSS Generator: 9 Sliders That Write the CodeA CSS generator is a small panel that turns a CSS value into a control you can move: drag a slider, read the code. Nine See all demos in this post


