Component · 2026 · MIT

CO'WATCH

A clock with five faces, two movements, five palettes and a tick you can hear. Every knob below is a real prop - change one and the watch and its snippet both answer immediately.

A clock is the smallest honest test of a component library. It has to be correct, it has to run forever, and it cannot lie about the time on the first frame.

CO'WATCH is one clock rendered five ways. The faces do not each own a timer - they are handed four numbers between zero and one and asked to draw. That is what keeps the second hand on the digital readout and the second ring on the radio face in exact agreement, and it is why adding a sixth face is a rendering problem rather than a timekeeping one.

It ships as two components. CoWatch is the watch and holds no settings of its own; every knob is a prop, which is what lets the form on this page drive it. CoWatchApp is the product: the same watch under its own on-canvas chrome, holding the state that chrome edits.

Demo

The snippet under the form is printed from the same object that is driving the watch, and it omits any prop still sitting at its default - so it is what you would have written by hand.

Live

12369

Face

Movement

Quartz steps on the second. Mechanical sweeps on every frame.

Scheme

Each palette carries its own light and dark rendering.

Swatch

Sound

Nothing is heard until you ask for it - muted never opens an audio context at all.

Volume

80

Dial

72%

A share of the shorter edge of whatever box the watch is given.

Date

co-watch.tsx
<CoWatch />

Install

Copy ui/watch/ into your project. It is six files and no runtime dependency beyond React - the two packages below are only the class-merging helper the components use for className.

npm install clsx tailwind-merge
Files
ui/watch/
  co-watch.tsx      the two components
  faces.tsx         the five dials
  chrome.tsx        segmented, slider, buttons, icons
  theme.ts          palettes and CSS custom properties
  use-tick.ts       the clock
  use-tick-sound.ts the tick

Source

Every file, as it is in this repository - read at build time, not transcribed. Take the open one to the clipboard, or the whole folder as a zip written in your own browser.

ui/watch/co-watch.tsx

"use client";

import { useState } from "react";

import { cn } from "../../lib/cn";
import {
  Field,
  MoonIcon,
  PaletteIcon,
  PillSlider,
  RoundButton,
  Segmented,
  SunIcon,
  Toggle,
} from "./chrome";
import { FACES, type FaceId } from "./faces";
import {
  SWATCHES,
  swatchDot,
  themeVars,
  type Scheme,
  type Swatch,
  type SwatchId,
} from "./theme";
import { useTick } from "./use-tick";
import { useTickSound, type SoundMode } from "./use-tick-sound";

// CO'WATCH, in two halves.
//
// `CoWatch` is the watch itself and holds no settings of its own - every knob
// is a prop. That is what lets the documentation page put its edit form in
// charge and have the demo answer immediately, and what lets the home page
// render a silent miniature by passing three props and nothing else.
//
// `CoWatchApp` is the product: the same watch under its own on-canvas chrome,
// holding the state the chrome edits. Splitting them means the controls are
// never fighting an outer form for ownership of the same value.

export type Movement = "quartz" | "mechanical";
export type { FaceId, Scheme, SoundMode, SwatchId };

export const FACE_OPTIONS = [
  { value: "digital", label: "Digital" },
  { value: "radio", label: "Radio" },
  { value: "orbit", label: "Orbit" },
  { value: "analog", label: "Analog" },
  { value: "big", label: "Big" },
] as const satisfies readonly { value: FaceId; label: string }[];

export const MOVEMENT_OPTIONS = [
  { value: "quartz", label: "Quartz" },
  { value: "mechanical", label: "Mechanical" },
] as const satisfies readonly { value: Movement; label: string }[];

export const SOUND_OPTIONS = [
  { value: "mute", label: "Mute" },
  { value: "system", label: "System" },
  { value: "watch", label: "Watch" },
] as const satisfies readonly { value: SoundMode; label: string }[];

export const SCHEME_OPTIONS = [
  { value: "light", label: "Light" },
  { value: "dark", label: "Dark" },
] as const satisfies readonly { value: Scheme; label: string }[];

const MODE_OPTIONS = [
  { value: "watch", label: "Watch" },
  { value: "focus", label: "Focus" },
] as const;

type Mode = (typeof MODE_OPTIONS)[number]["value"];

export type CoWatchProps = {
  /** Which dial to draw. */
  face?: FaceId;
  /** Quartz steps once a second; mechanical sweeps every frame. */
  movement?: Movement;
  /** Light or dark rendering of the chosen swatch. */
  scheme?: Scheme;
  /** Palette id - drives background and hand colour together. */
  swatch?: SwatchId;
  /** Tick sound. `mute` never opens an audio context at all. */
  sound?: SoundMode;
  /** Tick volume, 0-100. Ignored while muted. */
  volume?: number;
  /** Show the date pill in the corner. */
  showDate?: boolean;
  /** Dial size as a percentage of the shorter container edge. */
  dial?: number;
  /** Fill the viewport. Off inside a card or a demo frame. */
  full?: boolean;
  className?: string;
};

function resolveSwatch(id: SwatchId): Swatch {
  return SWATCHES.find((s) => s.id === id) ?? SWATCHES[0];
}

export function CoWatch({
  face = "big",
  movement = "quartz",
  scheme = "dark",
  swatch = "white",
  sound = "mute",
  volume = 80,
  showDate = true,
  dial = 72,
  full = false,
  className,
}: CoWatchProps) {
  const now = useTick(movement);

  // getTime() === 0 is the pre-hydration placeholder from useTick. Locale
  // formatting differs between server and browser, so the date is held back
  // until the first client tick replaces it.
  const mounted = now.getTime() > 0;
  useTickSound(now.getSeconds(), sound, volume, mounted);

  const ms = movement === "mechanical" ? now.getMilliseconds() / 1000 : 0;
  const seconds = (now.getSeconds() + ms) / 60;
  const minutes = (now.getMinutes() + seconds) / 60;
  const hours = ((now.getHours() % 12) + minutes) / 12;

  const Face = FACES[face];
  const dateLabel = mounted
    ? now.toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short" })
    : "";

  return (
    <div
      data-surface="cowatch"
      style={themeVars(resolveSwatch(swatch), scheme)}
      className={cn(
        "relative isolate size-full overflow-hidden bg-(--w-bg) text-(--w-ink)",
        "transition-colors duration-500 select-none",
        full && "min-h-svh",
        className,
      )}
    >
      {/* Two nested size containers: the outer one turns `dial` into a share of
          the shorter edge of whatever box the watch was given, the inner one is
          what the faces measure their type and hands against. */}
      <div
        className="absolute inset-0 grid place-items-center p-[4%]"
        style={{ containerType: "size" }}
      >
        <div
          className="aspect-square"
          style={{ width: `min(${dial}cqh, ${dial}cqw)`, containerType: "size" }}
        >
          <Face
            hours={hours}
            minutes={minutes}
            seconds={seconds}
            date={now}
            accent="var(--w-ink)"
            smooth={movement === "mechanical"}
          />
        </div>
      </div>

      {showDate ? (
        <div className="absolute right-6 bottom-6 z-20 min-w-28 rounded-full bg-(--w-raised) px-5 py-3 text-center text-sm font-medium text-(--w-ink)">
          {dateLabel}
        </div>
      ) : null}
    </div>
  );
}

export function CoWatchApp() {
  const [mode, setMode] = useState<Mode>("watch");
  const [panelOpen, setPanelOpen] = useState(true);
  const [face, setFace] = useState<FaceId>("big");
  const [movement, setMovement] = useState<Movement>("quartz");
  const [scheme, setScheme] = useState<Scheme>("dark");
  const [swatch, setSwatch] = useState<SwatchId>("white");
  const [sound, setSound] = useState<SoundMode>("watch");
  const [volume, setVolume] = useState(80);
  const [dial, setDial] = useState(72);
  const [showDate, setShowDate] = useState(true);

  const showPanel = mode === "watch" && panelOpen;

  return (
    <div
      data-surface="cowatch"
      style={themeVars(resolveSwatch(swatch), scheme)}
      className="relative isolate size-full min-h-svh overflow-hidden bg-(--w-bg) text-(--w-ink) transition-colors duration-500 select-none"
    >
      <CoWatch
        face={face}
        movement={movement}
        scheme={scheme}
        swatch={swatch}
        sound={sound}
        volume={volume}
        dial={dial}
        showDate={showDate}
        full
        className="absolute inset-0"
      />

      {/* Top-left: mode switch, panel toggle, scheme */}
      <div className="absolute top-6 left-6 z-20 flex items-center gap-3">
        <Segmented options={MODE_OPTIONS} value={mode} onChange={setMode} label="Mode" />
        <RoundButton
          label="Toggle preferences"
          active={showPanel}
          onClick={() => {
            if (mode === "focus") {
              setMode("watch");
              setPanelOpen(true);
              return;
            }
            setPanelOpen((open) => !open);
          }}
        >
          <PaletteIcon />
        </RoundButton>
        <RoundButton
          label={scheme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
          onClick={() => setScheme((s) => (s === "dark" ? "light" : "dark"))}
        >
          {scheme === "dark" ? <SunIcon /> : <MoonIcon />}
        </RoundButton>
      </div>

      {/* Preferences. One card rather than a loose stack, so the form reads as
          an instrument panel laid over the dial instead of text spilled on it.
          It scrolls internally, which is what keeps it usable on a short
          window as well as a tall one. */}
      <div
        className={cn(
          "absolute top-24 left-6 z-20 w-[380px] max-w-[calc(100%-3rem)]",
          "max-h-[calc(100%-8rem)] overflow-y-auto overscroll-contain",
          "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
          "rounded-[28px] p-6 backdrop-blur-2xl",
          "bg-(--w-surface)/70 shadow-2xl ring-1 ring-(--w-line)",
          "transition-all duration-300",
          showPanel
            ? "translate-y-0 scale-100 opacity-100"
            : "pointer-events-none -translate-y-2 scale-[0.98] opacity-0",
        )}
        aria-hidden={!showPanel}
      >
        <div className="flex items-baseline justify-between">
          <div className="text-base font-bold tracking-tight text-(--w-ink)">CO&apos;WATCH</div>
          <div className="text-xs text-(--w-muted)">Preferences</div>
        </div>

        <div className="mt-5 divide-y divide-(--w-line)">
          <Field label="Face">
            <Segmented
              options={FACE_OPTIONS}
              value={face}
              onChange={setFace}
              label="Face"
              className="flex-wrap"
            />
          </Field>

          <Field label="Movement" hint="Quartz steps on the second. Mechanical sweeps every frame.">
            <Segmented
              options={MOVEMENT_OPTIONS}
              value={movement}
              onChange={setMovement}
              label="Movement"
            />
          </Field>

          <Field label="Palette">
            <div className="flex items-center gap-2">
              {SWATCHES.map((option) => {
                const active = option.id === swatch;
                return (
                  <button
                    key={option.id}
                    type="button"
                    aria-label={`${option.id} theme`}
                    aria-pressed={active}
                    onClick={() => setSwatch(option.id)}
                    className={cn(
                      "grid size-10 cursor-pointer place-items-center rounded-full transition-colors",
                      active ? "bg-(--w-raised) ring-1 ring-(--w-line)" : "hover:bg-(--w-raised)",
                    )}
                  >
                    <span
                      className="size-6 rounded-full transition-colors"
                      style={{ background: swatchDot(option) }}
                    />
                  </button>
                );
              })}
            </div>
          </Field>

          <Field
            label="Sound"
            hint={
              sound === "mute"
                ? "Muted never opens an audio context at all."
                : "Volume applies to the tick, once a second."
            }
          >
            <Segmented options={SOUND_OPTIONS} value={sound} onChange={setSound} label="Sound" />
            {sound === "mute" ? null : (
              <div className="mt-1">
                <PillSlider value={volume} onChange={setVolume} label="Volume" />
              </div>
            )}
          </Field>

          <Field label="Dial" hint="A share of the shorter edge of the window.">
            <PillSlider value={dial} onChange={setDial} label="Dial size" min={40} max={92} />
          </Field>

          <Field label="Date">
            <div className="flex items-center gap-3">
              <Toggle checked={showDate} onChange={setShowDate} label="Show the date pill" />
              <span className="text-sm text-(--w-ink)">Show the date pill</span>
            </div>
          </Field>
        </div>
      </div>
    </div>
  );
}

co-watch.tsx - 328 lines. The file as it is in the repository, highlighted during the build.

Usage

The watch fills whatever box it is given, so give it one. It has no intrinsic height of its own unless you pass full, which claims the viewport.

import { CoWatch } from "@/ui/watch/co-watch";

export default function Page() {
  return (
    <div style={{ height: 420 }}>
      <CoWatch face="big" swatch="mint" scheme="dark" />
    </div>
  );
}
A watch inside a fixed-height box.

For the full product - mode switch, palette picker, sound, dial size - use the app instead. It takes no props because it owns the state its own controls edit.

import { CoWatchApp } from "@/ui/watch/co-watch";

// The watch under its own on-canvas chrome: mode switch, palette
// picker, sound, dial size. It holds its own state, so it takes
// no props - hand it a route and it is a product.
export default function WatchPage() {
  return <CoWatchApp />;
}
The watch as a page of its own.

Faces

Five readings of one clock, all running the same second. Big is four numerals leaning away from twelve. Analog is the conventional dial, sixty ticks and twelve numbers. Digital is tabular monospace. Radio and orbit drop hands entirely - one draws three arcs filling toward the hour, the other three bodies going round.

Movement

Quartz is a timeout re-aimed at the next second boundary after every tick, rather than a fixed one-second interval - an interval accumulates the browser's lateness and the watch slowly goes wrong. Mechanical trades the timer for a frame loop and folds the sub-second remainder into the hand positions, which is what makes the sweep continuous instead of stepped.

// Quartz: one timeout per second, re-aimed at the next
// boundary each time so it cannot drift.
schedule(1000 - (Date.now() % 1000));

// Mechanical: a frame loop, and the sub-second remainder
// folded into the hand positions.
const ms = now.getMilliseconds() / 1000;
const seconds = (now.getSeconds() + ms) / 60;

The step transition is disabled for mechanical. Easing a hand that already moves every frame fights the sweep and shows up as a faint rubberiness.

Palette

A swatch is not a colour, it is a pair. Each one carries its own light and dark rendering, so switching scheme never lands on an unreadable combination that a single hue plus a filter would produce.

{
  id: "mint",
  light: { bg: "#d9f3cf", ink: "#16290f" },
  dark:  { bg: "#0b0b0c", ink: "#c8f0bf" },
}
theme.ts - one swatch.

Everything else is derived. The chrome - pills, tracks, the date capsule - is mixed from a neutral base with color-mix rather than picked per palette, so the controls stay legible on a tinted ground and a new swatch is two pairs of hex values and nothing else.

Sound

Both ticks are synthesised, so there is no audio file to ship. The watch click is a 30ms noise burst through a band-pass at 2.6kHz; the system blip is a sine with a fast attack and an exponential tail.

Muting does three things rather than one: the master gain goes to zero, the context is suspended, and no context is created in the first place until something is actually going to be heard. The first tick after mount is also skipped, so arriving on the page is silent.

Props

Everything CoWatch accepts. CoWatchApp accepts none of them - it holds this state itself.

PropTypeDefaultNotes
face"digital" | "radio" | "orbit" | "analog" | "big""big"Which dial to draw. All five read the same clock; they differ only in how they say it.
movement"quartz" | "mechanical""quartz"Quartz steps once on each second boundary. Mechanical sweeps on every animation frame.
scheme"light" | "dark""dark"Which of the palette's two renderings to use. Every swatch carries both.
swatch"white" | "black" | "mint" | "yellow" | "blue""white"Sets background and hand colour together, as one decision rather than two.
sound"mute" | "system" | "watch""mute"Watch is a dry mechanical click, system a soft blip. Mute never opens an audio context.
volumenumber80Tick volume, 0-100. Inert while muted.
showDatebooleantrueThe date pill in the corner. Off for miniatures, where there is no room to read it.
dialnumber72Dial size as a percentage of the shorter edge of the container, not of the viewport.
fullbooleanfalseClaim the viewport height. Leave it off inside a card, a stage, or a grid cell.
classNamestring-Merged over the component's own classes, so a later utility wins.

Notes

The things that were not obvious while building it, and would be easy to undo by accident.

// Server and first client render both get 0, so the markup
// matches. The real time arrives on the first tick.
const time = useSyncExternalStore(
  subscribe,
  () => stamp.current,
  () => 0,
);
use-tick.ts - the placeholder.
  • The clock starts at zero. Rendering the real time on the server guarantees a hydration mismatch, because the client is always a moment later. The store returns 0 on the server and for the first client render, and the date label is held back until a real tick replaces it - locale formatting differs between the two as well.
  • Sizing is container-relative, not viewport-relative. The faces measure their type and hands in cqmin, and dialis a share of the container's shorter edge. That is what lets the same component be a full-screen watch and a 160px miniature with no separate small variant.
  • The second hand keeps its own red. It is the one colour that does not follow the palette. A second hand that re-tints with the dial stops being findable at a glance, which is the only job it has.
  • Tailwind runs without preflight here. This site is otherwise hand-written CSS, and the reset would flatten it. The utilities sit in a cascade layer so unlayered site rules win a tie, and the one part of preflight worth keeping - neutralising native button chrome - is scoped by hand to [data-surface].