06Lap stopwatch
A stopwatch that ticks in hundredths; space starts and stops it, the L key stamps a lap, and stamped laps survive a refresh.
Best forworkout logs, timing talks at a seminar
font-variant-numericrequestAnimationFramearia-keyshortcuts

<main class="stage">
<section class="sw is-demo" id="sw">
<p class="sw__time" id="swTime" role="timer" aria-live="off">
<span class="sw__main" id="swMain">00:00</span><span class="sw__sep">.</span><span class="sw__cs" id="swCs">00<span class="sw__reels" aria-hidden="true"><span class="sw__reel"><span class="sw__reelInner">0<br>1<br>2<br>3<br>4<br>5<br>6<br>7<br>8<br>9<br>0</span></span><span class="sw__reel sw__reel--fast"><span class="sw__reelInner">0<br>1<br>2<br>3<br>4<br>5<br>6<br>7<br>8<br>9<br>0<br>1<br>2<br>3<br>4<br>5<br>6<br>7<br>8<br>9<br>0</span></span></span></span>
</p>
<div class="sw__acts">
<button class="sw__btn" id="swToggle" type="button" aria-keyshortcuts="Space">Start</button>
<button class="sw__btn sw__btn--ghost" id="swLap" type="button" aria-keyshortcuts="L">Lap</button>
<button class="sw__btn sw__btn--ghost" id="swReset" type="button" aria-keyshortcuts="R">Reset</button>
</div>
<div class="sw__lapBox">
<ul class="sw__laps" id="swLaps"></ul>
<ul class="sw__laps sw__laps--ghost" aria-hidden="true">
<li class="sw__lap sw__lap--g1"><b class="sw__lapNo">Lap 3</b><span class="sw__lapAt">00:00.62</span></li>
<li class="sw__lap sw__lap--g2"><b class="sw__lapNo">Lap 2</b><span class="sw__lapAt">00:00.41</span></li>
<li class="sw__lap sw__lap--g3"><b class="sw__lapNo">Lap 1</b><span class="sw__lapAt">00:00.18</span></li>
</ul>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 랩 스톱워치 — 큰 표시줄은 고정폭 숫자(font-variant-numeric: tabular-nums)라 자릿수가 바뀌어도 안 흔들린다.
// 정적 표시: 시간과 랩 목록은 JS 가 글자로 찍는다(새로고침 뒤에도 localStorage 에서 되살아난다).
// .is-demo 미리보기: 0.01초 자리 두 칸을 세로 릴로 덮어 steps 로 숫자를 갈아 끼우고,
// 랩 목록 자리에는 대역 행 세 줄이 차례로 미끄러져 들어온다. 사람이 버튼을 누르면 둘 다 사라진다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.sw { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 300px; }
.sw__time {
margin: 0;
font-weight: 800; font-size: clamp(28px, 9vw, 44px); line-height: 1;
font-variant-numeric: tabular-nums;
color: $subject-ink;
letter-spacing: -.02em;
}
.sw__sep { color: rgba($subject-ink, .72); }
.sw__cs { position: relative; display: inline-block; }
// 미리보기 전용 숫자 릴 — 정적 경로에는 아예 없다(display:none). 켜질 때만 진짜 숫자를 덮는다
.sw__reels { display: none; position: absolute; left: 0; right: 0; top: 0; bottom: 0; color: $subject-ink; }
.sw__reel { flex: 1 1 0; overflow: hidden; height: 1em; line-height: 1; text-align: center; }
.sw__reelInner { display: block; line-height: 1; }
.sw__acts { display: flex; gap: $sp-2; }
.sw__btn {
padding: $sp-2 $sp-3;
border: 2px solid $color; border-radius: $r-pill;
background-color: $color; color: $subject-cream; // 진파랑 면 + 크림 글자 5.98:1
font-weight: 700; font-size: clamp(10px, 2.6vw, 12px);
cursor: pointer;
}
.sw__btn--ghost { border-color: $subject-ink; background-color: $stage-paper; color: $subject-ink; }
.sw__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.sw__lapBox { position: relative; min-height: clamp(56px, 22vw, 92px); }
.sw__laps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-1; }
.sw__lap {
display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2;
padding: $sp-1 $sp-2; border-radius: $r-sm;
background-color: $stage-paper;
}
.sw__lapNo { font-weight: 800; font-size: clamp(8px, 2.2vw, 10px); color: rgba($subject-ink, .72); }
.sw__lapAt {
font-weight: 800; font-size: clamp(10px, 2.6vw, 12px);
font-variant-numeric: tabular-nums; color: $subject-ink;
}
// 새로 찍힌 랩만 한 번 미끄러져 들어온다 — 목록을 다시 그리지 않고 맨 앞에 끼워 넣기 때문이다
.sw__lap.is-new {
animation-name: swLapIn;
animation-duration: $dur-enter;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes swLapIn {
from { opacity: 0; translate: 0 -70%; }
to { opacity: 1; translate: 0 0; }
}
// ── 격자 미리보기 ─────────────────────────────────────────────
.sw__laps--ghost { display: none; position: absolute; left: 0; right: 0; top: 0; }
.sw.is-demo .sw__laps--ghost { display: flex; }
.sw.is-demo .sw__laps:not(.sw__laps--ghost) { visibility: hidden; }
.sw.is-demo .sw__cs { color: transparent; } // 진짜 숫자는 자리만 지키고 릴이 대신 보인다
.sw.is-demo .sw__reels { display: flex; }
.sw.is-demo .sw__reelInner {
animation-duration: $duration;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.sw.is-demo .sw__reel .sw__reelInner {
animation-name: swReelSlow;
animation-timing-function: steps(10, end); // 0 부터 9 까지 열 칸을 컷으로
}
.sw.is-demo .sw__reel--fast .sw__reelInner {
animation-name: swReelFast;
animation-timing-function: steps(20, end); // 0.01초 자리는 두 바퀴를 돈다
}
@keyframes swReelSlow {
from { translate: 0 0; }
to { translate: 0 -10em; }
}
@keyframes swReelFast {
from { translate: 0 0; }
to { translate: 0 -20em; }
}
.sw__lap--g1 { animation-delay: 0ms; }
.sw__lap--g2 { animation-delay: 200ms; }
.sw__lap--g3 { animation-delay: 400ms; }
// 미끄러짐과 나타남을 **두 애니메이션으로 나눈다** — 나타남은 steps 컷이라 반투명 프레임이 아예 없다
// (여닫이 페이드 중간 프레임이 poster 로 뽑히는 함정. 미끄러짐만 이징으로 보인다)
.sw.is-demo .sw__laps--ghost .sw__lap {
animation-name: swGhostSlide, swGhostShow;
animation-duration: $duration, $duration;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: both, both;
}
@keyframes swGhostSlide {
0% { translate: 0 -70%; }
16%, 86% { translate: 0 0; }
87%, 100% { translate: 0 -70%; }
}
@keyframes swGhostShow {
0% { opacity: 0; }
3%, 86% { opacity: 1; }
87%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.sw { gap: $sp-2; }
.sw__acts { gap: $sp-1; }
.sw__btn { padding: $sp-1 $sp-2; }
}
// reduce 에서는 릴과 미끄러짐을 끈다 — 숫자는 그대로 찍히고 랩 행은 제자리에 바로 나타난다
@media (prefers-reduced-motion: reduce) {
.sw__lap.is-new { animation: none !important; }
.sw.is-demo .sw__reelInner,
.sw.is-demo .sw__laps--ghost .sw__lap { animation: none !important; }
}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);
}
.sw {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 300px;
}
.sw__time {
margin: 0;
font-weight: 800;
font-size: clamp(28px, 9vw, 44px);
line-height: 1;
font-variant-numeric: tabular-nums;
color: #17141a;
letter-spacing: -0.02em;
}
.sw__sep {
color: rgba(23, 20, 26, 0.72);
}
.sw__cs {
position: relative;
display: inline-block;
}
.sw__reels {
display: none;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
color: #17141a;
}
.sw__reel {
flex: 1 1 0;
overflow: hidden;
height: 1em;
line-height: 1;
text-align: center;
}
.sw__reelInner {
display: block;
line-height: 1;
}
.sw__acts {
display: flex;
gap: 8px;
}
.sw__btn {
padding: 8px 12px;
border: 2px solid #0a4fe8;
border-radius: 999px;
background-color: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
cursor: pointer;
}
.sw__btn--ghost {
border-color: #17141a;
background-color: #ffffff;
color: #17141a;
}
.sw__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.sw__lapBox {
position: relative;
min-height: clamp(56px, 22vw, 92px);
}
.sw__laps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.sw__lap {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
background-color: #ffffff;
}
.sw__lapNo {
font-weight: 800;
font-size: clamp(8px, 2.2vw, 10px);
color: rgba(23, 20, 26, 0.72);
}
.sw__lapAt {
font-weight: 800;
font-size: clamp(10px, 2.6vw, 12px);
font-variant-numeric: tabular-nums;
color: #17141a;
}
.sw__lap.is-new {
animation-name: swLapIn;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
}
@keyframes swLapIn {
from {
opacity: 0;
translate: 0 -70%;
}
to {
opacity: 1;
translate: 0 0;
}
}
.sw__laps--ghost {
display: none;
position: absolute;
left: 0;
right: 0;
top: 0;
}
.sw.is-demo .sw__laps--ghost {
display: flex;
}
.sw.is-demo .sw__laps:not(.sw__laps--ghost) {
visibility: hidden;
}
.sw.is-demo .sw__cs {
color: transparent;
}
.sw.is-demo .sw__reels {
display: flex;
}
.sw.is-demo .sw__reelInner {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.sw.is-demo .sw__reel .sw__reelInner {
animation-name: swReelSlow;
animation-timing-function: steps(10, end);
}
.sw.is-demo .sw__reel--fast .sw__reelInner {
animation-name: swReelFast;
animation-timing-function: steps(20, end);
}
@keyframes swReelSlow {
from {
translate: 0 0;
}
to {
translate: 0 -10em;
}
}
@keyframes swReelFast {
from {
translate: 0 0;
}
to {
translate: 0 -20em;
}
}
.sw__lap--g1 {
animation-delay: 0ms;
}
.sw__lap--g2 {
animation-delay: 200ms;
}
.sw__lap--g3 {
animation-delay: 400ms;
}
.sw.is-demo .sw__laps--ghost .sw__lap {
animation-name: swGhostSlide, swGhostShow;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: both, both;
}
@keyframes swGhostSlide {
0% {
translate: 0 -70%;
}
16%, 86% {
translate: 0 0;
}
87%, 100% {
translate: 0 -70%;
}
}
@keyframes swGhostShow {
0% {
opacity: 0;
}
3%, 86% {
opacity: 1;
}
87%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.sw {
gap: 8px;
}
.sw__acts {
gap: 4px;
}
.sw__btn {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sw__lap.is-new {
animation: none !important;
}
.sw.is-demo .sw__reelInner,
.sw.is-demo .sw__laps--ghost .sw__lap {
animation: none !important;
}
}// Lap 스톱워치 — 시계는 requestAnimationFrame 한 줄기로만 흐르고, 화면 글자는 그 값을 0.01초로 잘라 찍는다.
// 경과시간과 Lap 배열은 키 하나(p080-06-stopwatch)에 JSON 으로 묶어 저장해 새로고침 뒤에도 이어진다.
(function () {
var KEY = 'p080-06-stopwatch';
var START = 'Start';
var STOP = 'Stop';
var LAP_NO = 'Lap {n}';
var MAX = 4;
var root = document.getElementById('sw');
var mainEl = document.getElementById('swMain');
var csEl = document.getElementById('swCs');
var listEl = document.getElementById('swLaps');
var toggleEl = document.getElementById('swToggle');
var running = false, elapsed = 0, startAt = 0, base = 0, count = 0, laps = [];
function pad(n) { return (n < 10 ? '0' : '') + n; }
function fmt(ms) {
return pad(Math.floor(ms / 60000)) + ':' + pad(Math.floor(ms / 1000) % 60) + '.' + pad(Math.floor(ms / 10) % 100);
}
function save() {
try { window.localStorage.setItem(KEY, JSON.stringify({ elapsed: Math.round(elapsed), count: count, laps: laps })); } catch (e) { /* 저장소가 막힌 창은 건너뛴다 */ }
}
function load() {
try {
var raw = JSON.parse(window.localStorage.getItem(KEY) || '{}');
elapsed = Number(raw.elapsed) || 0;
laps = [].concat(raw.laps || []).slice(0, MAX);
count = Number(raw.count) || laps.length;
} catch (e) { elapsed = 0; laps = []; count = 0; }
}
function row(item, fresh) {
var li = document.createElement('li');
li.className = 'sw__lap' + (fresh ? ' is-new' : '');
var no = document.createElement('b');
no.className = 'sw__lapNo';
no.textContent = LAP_NO.replace('{n}', String(item.n));
var t = document.createElement('span');
t.className = 'sw__lapAt';
t.textContent = item.at;
li.appendChild(no); li.appendChild(t);
return li;
}
function paint() {
var text = fmt(elapsed);
mainEl.textContent = text.slice(0, 5);
csEl.firstChild.nodeValue = text.slice(6);
toggleEl.textContent = running ? STOP : START;
}
function tick() {
if (!running) { return; }
elapsed = base + (Date.now() - startAt);
paint();
window.requestAnimationFrame(tick);
}
function toggle() {
root.classList.remove('is-demo');
running = !running;
if (running) { base = elapsed; startAt = Date.now(); window.requestAnimationFrame(tick); } else { save(); }
paint();
}
function lap() {
root.classList.remove('is-demo');
count++;
laps.unshift({ n: count, at: fmt(elapsed) });
laps = laps.slice(0, MAX);
listEl.insertBefore(row(laps[0], true), listEl.firstChild);
while (listEl.children.length > MAX) { listEl.removeChild(listEl.lastChild); }
save();
}
function reset() {
root.classList.remove('is-demo');
running = false; elapsed = 0; base = 0; count = 0; laps = [];
listEl.textContent = '';
save(); paint();
}
toggleEl.addEventListener('click', toggle);
document.getElementById('swLap').addEventListener('click', lap);
document.getElementById('swReset').addEventListener('click', reset);
document.addEventListener('keydown', function (e) {
var k = (e.key || '').toLowerCase();
if (e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); toggle(); }
if (k === 'l') { e.preventDefault(); lap(); }
if (k === 'r') { e.preventDefault(); reset(); }
});
load();
for (var i = laps.length - 1; i >= 0; i--) { listEl.insertBefore(row(laps[i], false), listEl.firstChild); }
paint();
})();// StopwatchLaps — vanilla 판(run/080/demo/06-stopwatch-laps/index.html) 의 React 포트.
// 시계는 requestAnimationFrame 한 줄기, 화면 글자는 그 값을 0.01초로 자른 것.
// 경과시간·랩·랩 번호는 키 하나(p080-06-stopwatch)에 JSON 으로 묶어 저장한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StopwatchLaps.module.scss";
export type StopwatchLapsProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
type NumBox = { current: number };
type Lap = { n: number; at: string };
const KEY = "p080-06-stopwatch";
const START = "시작";
const STOP = "정지";
const LAP = "랩";
const RESET = "초기화";
const LAP_NO = "{n}번째";
const MAX = 4;
const SLOW_REEL = "01234567890";
const FAST_REEL = "012345678901234567890";
function pad(n: number): string { return (n < 10 ? "0" : "") + n; }
function fmt(ms: number): string {
return pad(Math.floor(ms / 60000)) + ":" + pad(Math.floor(ms / 1000) % 60) + "." + pad(Math.floor(ms / 10) % 100);
}
export default function StopwatchLaps({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
accentColor = "#0a4fe8",
}: StopwatchLapsProps) {
const [elapsed, setElapsed] = useState(0);
const [laps, setLaps] = useState<Lap[]>([]);
const [count, setCount] = useState(0);
const [running, setRunning] = useState(false);
const [fresh, setFresh] = useState(0);
const [demo, setDemo] = useState(true);
const baseRef = useRef(0) as NumBox;
const startRef = useRef(0) as NumBox;
const rafRef = useRef(0) as NumBox;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": accentColor };
const text = fmt(elapsed);
function save(ms: number, list: Lap[], no: number) {
try {
window.localStorage.setItem(KEY, JSON.stringify({ elapsed: Math.round(ms), count: no, laps: list }));
} catch (e) { /* 저장소가 막힌 창은 건너뛴다 */ }
}
useEffect(() => {
try {
const raw = JSON.parse(window.localStorage.getItem(KEY) || "{}");
const list: Lap[] = ([] as Lap[]).concat(raw.laps || []).slice(0, MAX);
setElapsed(Number(raw.elapsed) || 0);
setLaps(list);
setCount(Number(raw.count) || list.length);
} catch (e) { /* 못 읽으면 0 부터 */ }
}, []);
useEffect(() => {
if (!running) { return; }
let alive = true;
const step = () => {
if (!alive) { return; }
setElapsed(baseRef.current + (Date.now() - startRef.current));
rafRef.current = window.requestAnimationFrame(step);
};
rafRef.current = window.requestAnimationFrame(step);
return () => { alive = false; window.cancelAnimationFrame(rafRef.current); };
}, [running]);
function onToggle() {
setDemo(false);
if (running) { setRunning(false); save(elapsed, laps, count); return; }
baseRef.current = elapsed;
startRef.current = Date.now();
setRunning(true);
}
function onLap() {
setDemo(false);
const no = count + 1;
const list = ([{ n: no, at: fmt(elapsed) }] as Lap[]).concat(laps).slice(0, MAX);
setCount(no); setLaps(list); setFresh(no);
save(elapsed, list, no);
}
function onReset() {
setDemo(false);
baseRef.current = 0;
setRunning(false); setElapsed(0); setLaps([]); setCount(0); setFresh(0);
save(0, [], 0);
}
useEffect(() => {
function onKey(e: KeyEvt) {
const k = (e.key || "").toLowerCase();
if (e.key === " " || e.key === "Spacebar") { e.preventDefault(); onToggle(); }
if (k === "l") { e.preventDefault(); onLap(); }
if (k === "r") { e.preventDefault(); onReset(); }
}
window.addEventListener("keydown", onKey);
return () => { window.removeEventListener("keydown", onKey); };
});
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const ghost: Lap[] = [{ n: 3, at: "00:00.62" }, { n: 2, at: "00:00.41" }, { n: 1, at: "00:00.18" }];
const ghostCls = [styles.g1, styles.g2, styles.g3];
return (
<main className={styles.stage}>
<section className={cls} style={vars}>
<p className={styles.time} role="timer" aria-live="off">
<span className={styles.main}>{text.slice(0, 5)}</span>
<span className={styles.sep}>.</span>
<span className={styles.cs}>
{text.slice(6)}
<span className={styles.reels} aria-hidden="true">
<span className={styles.reel}>
<span className={styles.reelInner}>
{SLOW_REEL.split("").map((d, i) => (<span className={styles.reelDigit} key={i}>{d}</span>))}
</span>
</span>
<span className={`${styles.reel} ${styles.reelFast}`}>
<span className={styles.reelInner}>
{FAST_REEL.split("").map((d, i) => (<span className={styles.reelDigit} key={i}>{d}</span>))}
</span>
</span>
</span>
</span>
</p>
<div className={styles.acts}>
<button className={styles.btn} type="button" aria-keyshortcuts="Space" onClick={onToggle}>
{running ? STOP : START}
</button>
<button className={`${styles.btn} ${styles.btnGhost}`} type="button" aria-keyshortcuts="L" onClick={onLap}>{LAP}</button>
<button className={`${styles.btn} ${styles.btnGhost}`} type="button" aria-keyshortcuts="R" onClick={onReset}>{RESET}</button>
</div>
<div className={styles.lapBox}>
<ul className={styles.laps}>
{laps.map((l) => (
<li className={[styles.lap, l.n === fresh ? styles.isNew : ""].filter(Boolean).join(" ")} key={l.n}>
<b className={styles.lapNo}>{LAP_NO.replace("{n}", String(l.n))}</b>
<span className={styles.lapAt}>{l.at}</span>
</li>
))}
</ul>
<ul className={`${styles.laps} ${styles.lapsGhost}`} aria-hidden="true">
{ghost.map((l, i) => (
<li className={`${styles.lap} ${ghostCls[i]}`} key={l.n}>
<b className={styles.lapNo}>{LAP_NO.replace("{n}", String(l.n))}</b>
<span className={styles.lapAt}>{l.at}</span>
</li>
))}
</ul>
</div>
</section>
</main>
);
}06 Lap stopwatch — Measured area 20.363% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · HTML CSS JS mini games: 9 copy-paste mini apps
The post this demo lives in
HTML CSS JS mini games: 9 copy-paste mini appsHTML CSS JS mini games are small browser toys and work tools built from plain markup. The nine here — quiz, calculator, See all demos in this post


