Teseria
All elements

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

Terminal
npm i framer-motion lucide-react

2. Files

Countdown.tsx217 lines
"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.css166 lines
/* 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;
  }
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { Countdown } from "./Countdown";
import "./Countdown.css";

export default function Demo() {
  return <Countdown />;
}