GlassKit UI
Components

Timer

A countdown readout: big tabular m:ss, an optional label, and a bar draining toward zero. Self-ticking (end-time anchored, no drift) with pause/resume via running; pass remaining to control it yourself.

1:30Rest

Pause, resume, or restart

600 × 600 · live

Installation

npx @glasskit-ui/cli add timer

Install the SDK (it provides GlassViewport, useDpad and the stylesheet), then copy these files into your project:

npm install @glasskit-ui/react
// components/lib/utils.tsexport type ClassValue = string | number | null | undefined | false;/** * Join truthy class names. Dependency-free on purpose: the lens components * style via bespoke semantic classes (no conflicting Tailwind utilities to * de-dupe), so this needs no clsx/tailwind-merge and resolves from anywhere * the registry is vendored. */export function cn(...inputs: ClassValue[]): string {  return inputs.filter(Boolean).join(" ");}/** * Accessible name from a free-form `label` prop: the label itself when it's a * plain string, otherwise undefined (a ReactNode can't become an aria-label). */export function stringLabel(label: unknown): string | undefined {  return typeof label === "string" ? label : undefined;}
// components/glasskit/progress.tsximport type { ReactNode } from "react";import { cn, stringLabel } from "../lib/utils";/** * <Progress> — emitted progress, two shapes: *   - "linear"  a continuous bar (also covers countdown: feed a decreasing *               value and a time label). Uses a native <progress>, so the *               dynamic fill needs no inline style. *   - "step"    discrete step-of-N dots (wizard / pinch-advance). * * `value` is clamped to [0, max]. For "step", value = completed steps. */export function Progress({  value,  max = 100,  variant = "linear",  label,  className,}: {  value: number;  max?: number;  variant?: "linear" | "step";  /** Optional caption shown with the bar (e.g. "3 of 5", "0:42 left"). */  label?: ReactNode;  className?: string;}) {  const clamped = Math.max(0, Math.min(value, max));  if (variant === "step") {    // Steps must be a sane integer count — a fractional or negative max    // would make Array.from misrender the dots.    const steps = Math.max(0, Math.floor(max));    return (      <div        className={cn("gk-steps", className)}        role="progressbar"        aria-valuenow={clamped}        aria-valuemin={0}        aria-valuemax={steps}        aria-label={stringLabel(label)}      >        {Array.from({ length: steps }, (_, i) => (          <span            key={i}            className={cn("gk-step", i < clamped && "gk-step--on")}          />        ))}      </div>    );  }  return (    <div className={cn("gk-progress", className)}>      <progress        className="gk-progress__el"        value={clamped}        max={max}        aria-label={stringLabel(label)}      />      {label != null ? (        <div className="gk-progress__meta t-caption">{label}</div>      ) : null}    </div>  );}
// components/glasskit/timer.tsx"use client";import { useEffect, useRef, useState, type ReactNode } from "react";import { cn, stringLabel } from "../lib/utils";import { Progress } from "./progress";/** * <Timer> — a countdown readout: big tabular m:ss (h:mm:ss past an hour), an * optional label, and a bar draining toward zero. Self-ticking by default: * give it `duration` and flip `running` to pause/resume — ticks are end-time * anchored (no setTimeout drift) and aligned to the second boundary. * `onComplete` fires once at zero. Pass `remaining` to control it instead: * the prop always wins, the component never ticks, and `onComplete` is yours * to call — you own the clock. */export function Timer({  duration,  remaining,  running = true,  label,  showBar = true,  onComplete,  className,}: {  /** Total seconds. Drives self-ticking and the drain bar's scale. */  duration?: number;  /** Controlled seconds left. Omit to self-tick from `duration`. */  remaining?: number;  /** Pause/resume the self-ticking countdown. */  running?: boolean;  label?: ReactNode;  /** Hide the drain bar (it needs `duration` for its scale). */  showBar?: boolean;  /** Fires once when a self-ticking countdown reaches zero. */  onComplete?: () => void;  className?: string;}) {  const controlled = remaining != null;  const [internal, setInternal] = useState(duration ?? 0);  // Latest-values refs: the anchor effect reads these at (re)start time so  // pausing/resuming re-anchors from the frozen value, and onComplete stays  // current without being an effect dependency.  const internalRef = useRef(internal);  internalRef.current = internal;  const onCompleteRef = useRef(onComplete);  onCompleteRef.current = onComplete;  // A new duration is a new countdown — reset. Runs before the anchor effect  // below (declaration order), so the ref is fresh when it re-anchors.  useEffect(() => {    setInternal(duration ?? 0);    internalRef.current = duration ?? 0;  }, [duration]);  useEffect(() => {    if (controlled || !running || internalRef.current <= 0) return;    // Anchor to a wall-clock end time: each tick recomputes from it, so    // timeout jitter never accumulates into a slow timer.    const endAt = Date.now() + internalRef.current * 1000;    let timer: ReturnType<typeof setTimeout>;    const schedule = () => {      timer = setTimeout(tick, ((endAt - Date.now() - 20) % 1000) + 20);    };    const tick = () => {      const left = Math.max(0, Math.ceil((endAt - Date.now()) / 1000));      setInternal(left);      if (left <= 0) {        onCompleteRef.current?.();        return;      }      schedule();    };    schedule();    return () => clearTimeout(timer);  }, [controlled, running, duration]);  const shown = controlled ? Math.max(0, remaining) : internal;  return (    <div      className={cn("gk-timer", className)}      role="timer"      aria-label={stringLabel(label) ?? "Timer"}    >      <span className="gk-timer__time">{formatSeconds(shown)}</span>      {label != null ? (        <span className="gk-timer__label t-caption">{label}</span>      ) : null}      {showBar && duration != null && duration > 0 ? (        // Decorative: the big time above carries the value (role="timer"),        // a visible Progress label here would just repeat the caption.        <div aria-hidden="true" className="gk-timer__bar">          <Progress value={shown} max={duration} />        </div>      ) : null}    </div>  );}function formatSeconds(total: number): string {  const s = Math.max(0, Math.floor(total));  const h = Math.floor(s / 3600);  const m = Math.floor((s % 3600) / 60);  const mm = h > 0 ? String(m).padStart(2, "0") : String(m);  return `${h > 0 ? `${h}:` : ""}${mm}:${String(s % 60).padStart(2, "0")}`;}

Usage

// self-ticking 5-minute countdown<Timer duration={300} label="Pasta" onComplete={notify} />// paused / resumed from app state<Timer duration={300} running={running} />// or controlled — you own the clock<Timer remaining={secondsLeft} duration={300} />

Props

Prop

Type