Countdown
Flip-clock digits. Each digit is a card split at the middle; when its number changes the upper half folds down carrying the new value and the lower half catches it.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// Countdown: flip-clock digits. Each digit is a card split at the middle; when its number changes the upper half folds down
// carrying the new value and the lower half catches it. The clock stores an end timestamp, so it stays exact while hidden.
import { animate, useMotionValue } from "framer-motion";
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { RotateCcw } from "lucide-react";
export type CountdownCue = "done" | "enable" | "disable" | "push";
export type CountdownProps = {
/** Seconds to count down from. */
seconds?: number;
/** Or a moment to count down to; wins over `seconds`. */
until?: Date;
/** Start counting on mount. */
autoStart?: boolean;
/** Called once when the count reaches zero. */
onDone?: () => void;
/** Card height in px. */
size?: number;
/** Card corner radius in px. */
corner?: number;
/** Sound hook: "done" at zero, "enable" / "disable" on start / pause, "push" on reset. */
onSound?: (cue: CountdownCue) => void;
className?: string;
style?: CSSProperties;
};
const UNITS = ["h", "m", "s"] as const;
const cn = (...a: (string | false | null | undefined)[]) => a.filter(Boolean).join(" ");
const still = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/** Reports whether the element is near the viewport and the tab is in front. */
function useVisible(el: React.RefObject<Element | null>) {
const [seen, setSeen] = useState(false);
const [front, setFront] = useState(true);
useEffect(() => {
const node = el.current;
if (!node || typeof IntersectionObserver === "undefined") return setSeen(true);
const obs = new IntersectionObserver((list) => setSeen(list[list.length - 1].isIntersecting), { rootMargin: "160px" });
obs.observe(node);
return () => obs.disconnect();
}, [el]);
useEffect(() => {
const sync = () => setFront(!document.hidden);
sync();
document.addEventListener("visibilitychange", sync);
return () => document.removeEventListener("visibilitychange", sync);
}, []);
return seen && front;
}
// The glyph morphs between two shapes, each a pair of quads (x,y of 4 corners). Stage 0 = pause bars, 1 = play triangle.
type Quad = readonly number[];
const BARS: readonly [Quad, Quad] = [[6, 4, 10, 4, 10, 20, 6, 20], [14, 4, 18, 4, 18, 20, 14, 20]];
const TRI: readonly [Quad, Quad] = [[6.5, 4, 13.25, 8, 13.25, 16, 6.5, 20], [13.25, 8, 20, 12, 20, 12, 13.25, 16]];
const blend = (from: Quad, to: Quad, k: number) => {
const pts = from.map((v, n) => v + (to[n] - v) * k);
return `M${pts.slice(0, 2).map((p) => p.toFixed(2)).join(" ")}` + [2, 4, 6].map((n) => `L${pts[n].toFixed(2)} ${pts[n + 1].toFixed(2)}`).join("") + "Z";
};
/** Pause bars fold into a play triangle and back. The tween drives the paths directly; React only renders the resting shape. */
function Glyph({ playing, size }: { playing: boolean; size: number }) {
const stage = useMotionValue(playing ? 0 : 1);
const root = useRef<SVGSVGElement>(null);
const leftBar = useRef<SVGPathElement>(null);
const rightBar = useRef<SVGPathElement>(null);
useLayoutEffect(() => {
const draw = (k: number) => {
const lean = Math.sin(Math.min(1, Math.max(0, k)) * Math.PI);
const shift = 1.6 * lean;
if (root.current) root.current.style.transform = `rotate(${((playing ? -9 : 9) * lean).toFixed(2)}deg) scale(${(1 - 0.13 * lean).toFixed(3)}, ${(1 + 0.11 * lean).toFixed(3)})`;
[leftBar.current, rightBar.current].forEach((bar, n) => {
bar?.setAttribute("d", blend(BARS[n], TRI[n], k));
bar?.setAttribute("transform", `translate(${(n ? -shift : shift).toFixed(2)} 0)`);
});
};
draw(stage.get());
const off = stage.on("change", draw);
const run = animate(stage, playing ? 0 : 1, { duration: still() ? 0 : 0.3, ease: [0.22, 1, 0.36, 1] });
return () => {
off();
run.stop();
};
}, [playing, stage]);
return (
<svg ref={root} width={size} height={size} viewBox="0 0 24 24" aria-hidden fill="currentColor" stroke="currentColor" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" style={{ transformOrigin: "50% 50%" }}>
<path ref={leftBar} />
<path ref={rightBar} />
</svg>
);
}
/** Seconds still to go while running (or frozen while paused). Ticks only while `watching`, aligned to the second boundary. */
function useClock(initial: number, autoStart: boolean, watching: boolean, finished: () => void) {
const [secs, setSecs] = useState(initial);
const [running, setRunning] = useState(autoStart);
const deadline = useRef(0);
const fired = useRef(false);
const onEnd = useRef(finished);
onEnd.current = finished;
const remaining = () => Math.max(0, Math.ceil((deadline.current - Date.now()) / 1000));
// one timer for the very end, so completion is never late even off screen
useEffect(() => {
if (!running) return;
deadline.current = Date.now() + secs * 1000;
const id = window.setTimeout(() => {
setSecs(0);
setRunning(false);
if (!fired.current) {
fired.current = true;
onEnd.current();
}
}, secs * 1000);
return () => clearTimeout(id);
// the deadline is set once per run; `secs` is only read at the start
}, [running]);
useEffect(() => {
if (!running || !watching) return;
let id = 0;
const beat = () => {
setSecs(remaining());
const rest = deadline.current - Date.now();
if (rest > 0) id = window.setTimeout(beat, (rest % 1000) + 15);
};
beat();
return () => clearTimeout(id);
}, [running, watching]);
return {
secs,
running,
toggle: () => {
if (running) setSecs(remaining());
setRunning(!running && secs > 0);
},
reset: (to: number) => {
fired.current = false;
setRunning(false);
setSecs(to);
},
};
}
export function Countdown({ seconds = 754, until, autoStart = true, onDone, size = 44, corner = 10, onSound, className, style }: CountdownProps) {
const start = until ? Math.max(0, Math.round((until.getTime() - Date.now()) / 1000)) : seconds;
const root = useRef<HTMLDivElement>(null);
const watching = useVisible(root);
const clock = useClock(start, autoStart, watching, () => {
onSound?.("done");
onDone?.();
});
const toggle = () => {
onSound?.(clock.running ? "disable" : "enable");
clock.toggle();
};
const reset = () => {
onSound?.("push");
clock.reset(start);
};
const hours = Math.floor(clock.secs / 3600);
const minutes = Math.floor((clock.secs % 3600) / 60);
const secondsLeft = clock.secs % 60;
const digits = [hours, minutes, secondsLeft].map((n) => String(n).padStart(2, "0"));
const radius = Math.min(Math.max(corner, 0), size / 2);
return (
<div ref={root} className={cn("tcd-root", className)} style={{ "--tcd-h": `${size}px`, "--tcd-r": `${radius}px`, ...style } as CSSProperties}>
<div className="tcd-row" role="timer" aria-live="off" aria-label={`${hours} hours ${minutes} minutes ${secondsLeft} seconds left`}>
{digits.map((pair, n) => (
<span key={UNITS[n]} className="tcd-unit">
<span className="tcd-cards">
<FlipCard digit={pair[0]} />
<FlipCard digit={pair[1]} />
</span>
<span className="tcd-lab">{UNITS[n]}</span>
{n < 2 && <span className="tcd-colon" aria-hidden />}
</span>
))}
</div>
<div className="tcd-ctl">
<button type="button" aria-label={clock.running ? "Pause" : "Start"} data-on={clock.running || undefined} onClick={toggle}>
<Glyph playing={clock.running} size={16} />
</button>
<button type="button" aria-label="Reset" onClick={reset}>
<RotateCcw size={14} strokeWidth={2.2} />
</button>
</div>
</div>
);
}
/** One digit. The card shows the new value on top and the old one below until the fold finishes (520ms). */
function FlipCard({ digit }: { digit: string }) {
const [shown, setShown] = useState(digit);
const folding = shown !== digit;
useEffect(() => {
if (!folding) return;
const id = window.setTimeout(() => setShown(digit), 520);
return () => clearTimeout(id);
}, [folding, digit]);
const half = (cls: string, text: string) => (
<span className={`tcd-half ${cls}`}>
<i>{text}</i>
</span>
);
return (
<span className="tcd-card" data-flip={folding || undefined}>
{half("tcd-top", digit)}
{half("tcd-bot", shown)}
{half("tcd-top tcd-fold-top", shown)}
{half("tcd-bot tcd-fold-bot", digit)}
</span>
);
}
/* Countdown. Prefix tcd-. */
.tcd-root, .tcd-root *, .tcd-root *::before, .tcd-root *::after { box-sizing: border-box; }
.tcd-root { font-family: var(--ts-font, system-ui, sans-serif); }
:where(.tcd-root) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; }
:where(.tcd-root) :focus-visible { outline: 2px solid var(--ts-focus, #3446eb); outline-offset: 2px; }
.tcd-root {
--tcd-h: 44px;
--tcd-r: 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: var(--ts-fg);
user-select: none;
}
.tcd-row {
display: flex;
align-items: flex-start;
gap: 14px;
}
.tcd-unit {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.tcd-cards {
display: flex;
gap: 3px;
perspective: 700px;
}
.tcd-lab {
font-size: 12px;
font-weight: 500;
opacity: 0.45;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.tcd-colon {
position: absolute;
right: -10px;
top: calc(var(--tcd-h) / 2 - 7px);
width: 3px;
height: 14px;
background: radial-gradient(circle, currentColor 1.5px, transparent 1.6px) 0 0 / 3px 7px;
opacity: 0.4;
}
.tcd-card {
position: relative;
width: calc(var(--tcd-h) * 0.7);
height: var(--tcd-h);
font-size: calc(var(--tcd-h) * 0.58);
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
transform-style: preserve-3d;
}
.tcd-half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
background: var(--ts-raised);
box-shadow: var(--ts-edge);
backface-visibility: hidden;
}
.tcd-half i {
position: absolute;
left: 0;
right: 0;
height: var(--tcd-h);
line-height: var(--tcd-h);
text-align: center;
font-style: normal;
}
.tcd-top {
top: 0;
border-radius: var(--tcd-r) var(--tcd-r) 0 0;
transform-origin: 50% 100%;
}
.tcd-top i {
top: 0;
}
.tcd-bot {
bottom: 0;
border-radius: 0 0 var(--tcd-r) var(--tcd-r);
transform-origin: 50% 0;
}
.tcd-bot i {
bottom: 0;
}
.tcd-bot::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: rgba(var(--ts-fg-rgb), 0.1);
}
.tcd-fold-top,
.tcd-fold-bot {
z-index: 2;
opacity: 0;
}
.tcd-fold-bot {
transform: rotateX(90deg);
}
.tcd-card[data-flip] .tcd-fold-top {
opacity: 1;
animation: tcd-down 0.22s cubic-bezier(.22, 1, .36, 1) both;
}
.tcd-card[data-flip] .tcd-fold-bot {
opacity: 1;
animation: tcd-catch 0.32s cubic-bezier(.22, 1, .36, 1) 0.2s both;
}
@keyframes tcd-down {
0% {
transform: rotateX(0);
}
to {
transform: rotateX(-90deg);
}
}
@keyframes tcd-catch {
0% {
transform: rotateX(90deg);
}
to {
transform: rotateX(0);
}
}
.tcd-ctl {
display: flex;
gap: 6px;
}
.tcd-ctl button {
width: 34px;
height: 34px;
border-radius: 999px;
background: var(--ts-raised);
box-shadow: var(--ts-edge);
display: grid;
place-items: center;
transition: scale 0.22s cubic-bezier(.22, 1, .36, 1), background 0.16s cubic-bezier(.22, 1, .36, 1), color 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tcd-ctl button:hover {
background: color-mix(in srgb, var(--ts-fg) 6%, var(--ts-raised));
}
.tcd-ctl button:active {
scale: 0.94;
}
.tcd-ctl button[data-on] {
background: var(--ts-fg);
color: var(--ts-raised);
}
@media (prefers-reduced-motion: reduce) {
.tcd-card[data-flip] .tcd-fold-top,
.tcd-card[data-flip] .tcd-fold-bot {
animation: none;
opacity: 0;
}
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { Countdown } from "./Countdown";
import "./Countdown.css";
export default function Demo() {
return <Countdown />;
}