Tokens
Every value in the system, and the name it answers to. tokens/tokens.json is the authoring source:
tools/sync.mjs generates the CSS custom properties, the typed TS theme, the React Native theme and
the Tailwind preset from it, and this page is read from the same file — so a value cannot be
documented as one thing and shipped as another.
Reaching them
Four forms, one source. Take whichever your platform can read.
/* CSS custom properties — the six web libraries */
color: var(--hg-fg);
background: var(--hg-surface);
padding: var(--hg-space-16);
border-radius: var(--hg-radius-md);
// A typed object, for a canvas, a chart, or <meta name="theme-color">
import { light, dark, cssVar, space, radius, motion } from "./hourglass/theme/tokens";
light.accent; // "#242424"
cssVar.accent; // "var(--hg-accent)"
space[16]; // 16
motion.easing.out; // "cubic-bezier(0.16, 1, 0.3, 1)"
// React Native, where there are no custom properties to cascade
const theme = useTheme();
theme.colors.surface;
theme.target.min; // 44 on iOS, 48 on Android
// Optional: every token mapped onto Tailwind's scales
presets: [require("./hourglass/tailwind.preset")];
The two theme files stand alone. hourglass-tokens.css is useful without a single
component — link it and dark mode already follows the OS, with no JavaScript.
Colour
OKLCH is the source of truth; the hex beside it is the sRGB fallback, used verbatim by React
Native. Monochrome throughout — accent is near-black in light and near-white in
dark, the same neutral scale rather than a hue.
| Token | CSS | Light | Dark |
|---|---|---|---|
| bg | --hg-bg | #fcfcfcoklch(99% 0 0) | #141414oklch(12% 0 0) |
| surface | --hg-surface | #ffffffoklch(100% 0 0) | #1f1f1foklch(16% 0 0) |
| elevated | --hg-elevated | #f5f5f5oklch(97% 0 0) | #292929oklch(20% 0 0) |
| border | --hg-border | #dcdcdcoklch(89% 0 0) | #393939oklch(27% 0 0) |
| borderStrong | --hg-border-strong | #b4b4b4oklch(75% 0 0) | #5c5c5coklch(42% 0 0) |
| fg | --hg-fg | #1a1a1aoklch(15% 0 0) | #f5f5f5oklch(97% 0 0) |
| muted | --hg-muted | #666666oklch(46% 0 0) | #9a9a9aoklch(66% 0 0) |
| faint | --hg-faint | #9d9d9doklch(67% 0 0) | #616161oklch(44% 0 0) |
| accent | --hg-accent | #242424oklch(20% 0 0) | #edededoklch(94% 0 0) |
| accentFg | --hg-accent-fg | #fcfcfcoklch(99% 0 0) | #191919oklch(14% 0 0) |
Derived
Mixes of the colours above, so overriding a base carries through instead of leaving these
stranded. On the web they are color-mix(); React Native gets the resolved value.
| Token | CSS | Light | Dark |
|---|---|---|---|
| accentSoft | --hg-accent-soft | rgba(36, 36, 36, 0.12)accent at 12% over transparent | rgba(237, 237, 237, 0.12) |
| accentDim | --hg-accent-dim | #e0e0e0accent at 14% over surface | #3c3c3c |
| accentStrong | --hg-accent-strong | #222222accent at 82% over fg | #eeeeee |
| fgSoft | --hg-fg-soft | rgba(26, 26, 26, 0.06)fg at 6% over transparent | rgba(245, 245, 245, 0.06) |
Type
| Token | CSS | Stack |
|---|---|---|
| display | --hg-font-display | Inter, system-ui, -apple-system, "Segoe UI", sans-serif |
| body | --hg-font-body | Inter, system-ui, -apple-system, "Segoe UI", sans-serif |
| mono | --hg-font-mono | "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace |
The libraries ship no font files — a folder you copy into someone else's project has no business carrying licensed binaries. Load Inter and JetBrains Mono in the host app, or accept the fallbacks; the scale works either way.
| Step | CSS | Size | Weight | Tracking | Family |
|---|---|---|---|---|---|
| hero | --hg-text-hero | clamp(40px, 12vw, 96px) | 600 | -0.02em | display |
| h1 | --hg-text-h1 | 28px | 600 | -0.01em | display |
| h2 | --hg-text-h2 | 22px | 600 | -0.01em | display |
| h3 | --hg-text-h3 | 18px | 600 | -0.01em | display |
| body | --hg-text-body | 14px | 400 | 0 | body |
| small | --hg-text-small | 13px | 400 | 0 | body |
| caption | --hg-text-caption | 11px | 500 | 0.1em | mono · uppercase |
| micro | --hg-text-micro | 10px | 500 | 0.08em | mono |
10px is the floor. micro is the smallest step, and nothing renders
below it.
Space
| Step | CSS | Value | Notes |
|---|---|---|---|
| 2 | --hg-space-2 | 2px | |
| 4 | --hg-space-4 | 4px | |
| 8 | --hg-space-8 | 8px | |
| 12 | --hg-space-12 | 12px | |
| 16 | --hg-space-16 | 16px | |
| 20 | --hg-space-20 | 20px | |
| 24 | --hg-space-24 | 24px | |
| 32 | --hg-space-32 | 32px | |
| 44 | --hg-space-44 | 44px | |
| 64 | --hg-space-64 | 64px | |
| gutter | --hg-gutter | clamp(16px, 4vw, 44px) | The page margin. Grows with the viewport. |
Radius
| Step | CSS | Value | |
|---|---|---|---|
| sm | --hg-radius-sm | 8px | |
| md | --hg-radius-md | 12px | |
| lg | --hg-radius-lg | 16px | |
| xl | --hg-radius-xl | 22px | |
| 2xl | --hg-radius-2xl | 28px | |
| pill | --hg-radius-pill | 100px |
Hit targets
| Platform | CSS | Value | Notes |
|---|---|---|---|
| ios | --hg-target | 44px | The iOS floor, and the default. |
| android | --hg-target-android | 48px | Android asks for more; React Native picks the right one per platform. |
Every interactive element is at least this tall and wide, padding included. React Native resolves
the platform's own figure; the web takes 44.
Elevation
| Token | CSS | Light | Dark | Used for |
|---|---|---|---|---|
| sm | --hg-shadow-sm | 0 1px 2px rgba(26, 26, 26, 0.06), 0 8px 30px rgba(26, 26, 26, 0.06) | 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 30px rgba(0, 0, 0, 0.4) | A menu, a popover — something a few pixels off the page. |
| lg | --hg-shadow-lg | 0 10px 30px rgba(26, 26, 26, 0.08), 0 40px 90px rgba(26, 26, 26, 0.12) | 0 10px 30px rgba(0, 0, 0, 0.55), 0 40px 90px rgba(0, 0, 0, 0.65) | A sheet or a toast, the only things that genuinely float. |
React Native splits each shadow into shadowColor, shadowOffset,
shadowOpacity and shadowRadius for iOS, plus elevation for
Android — see the React Native theme.
Motion
| Token | CSS | Value | Used for |
|---|---|---|---|
| base | --hg-duration-base | 500ms | The default. Anything entering or leaving. |
| fast | --hg-duration-fast | 300ms | A state change on something already on screen. |
| stagger | --hg-stagger | 70ms | The gap between siblings in a cascade. |
| pulse | --hg-duration-pulse | 1200ms | One breath of a live indicator. |
| dialSweep | --hg-duration-dial | 800ms | A dial arc drawing itself. |
| reelDigitStagger | --hg-reel-digit-stagger | 55ms | The gap between digits in a reel, left to right. |
Easing
| Token | CSS | Curve | React Native |
|---|---|---|---|
| out | --hg-ease-out | cubic-bezier(0.16, 1, 0.3, 1)ease-out exponential | Easing.out(Easing.exp) |
| reel | --hg-ease-reel | cubic-bezier(0.25, 1, 0.5, 1)ease-out quart | Easing.out(Easing.poly(4)) |
Under prefers-reduced-motion the duration tokens collapse to 0.01ms, so
every animation lands on its final state with no travel — and nothing has to check a media query to
make that happen. See Motion.
Contrast floors
| Floor | Ratio | Applies to |
|---|---|---|
| body | 4.5:1 | Anything that is a sentence. |
| large | 3:1 | 18px and up, or 14px semibold and up. |
| icon | 3:1 | A glyph carrying meaning, and the edge of a control. |
Held in both modes. Disabled is the only state permitted to drop below the floor; the web shows a
visible focus ring; and mono numerals carry a spoken label, so a screen reader does not read
4:40 as digits.
Next
Components — all 23, with props, types and code samples.