Themes
One interface, written once against a typed token vocabulary, and 6 themes that supply different values for it. Switching theme changes one attribute on the root element. It is CSS only: no component rerenders for it, and no class or inline style changes.
Soft neutrals, underlined tabs, rounded corners, light shadows.
How it is built
Stylesheets name roles such as fill: 'accent' or edge: 'panel'. Each token resolves a role from the active theme, and a theme is an object that satisfies one type. On the web every theme field is a custom property, so the generated classes stay the same and only the values under data-theme differ.
1. A stylesheet
The button used throughout the demo. It is the same in every theme.
import type { Variants } from '@toned/core'
import { stylesheet } from '../system.ts'
/**
* A button, written once. Nothing here names a theme: every value is a role
* (`fill: 'accent'`, `edge: 'filled'`, `press: 'down'`) that the active theme
* resolves.
*/
export const buttonStyles = stylesheet({
Root: {
$kind: 'pressable',
reset: 'control',
flow: 'inline',
align: 'center',
justify: 'center',
gap: 2,
size: 'control',
padX: 4,
overflow: 'nowrap',
type: 'control',
fill: 'accent',
ink: 'on-accent',
edge: 'filled',
corner: 'control',
depth: 'button',
motion: 'themed',
// Text the theme draws around the label, e.g. `[ Save ]`.
affix: 'button',
':hover': { fill: 'accent-hover' },
':focus-visible': { focus: 'ring' },
':active': { press: 'down' },
},
}).variants(
(
$: Variants<{
tone: 'primary' | 'secondary' | 'danger' | 'quiet'
shape: 'label' | 'icon'
disabled: boolean
}>,
) => ({
[$.tone('secondary')]: {
Root: {
fill: 'neutral',
ink: 'on-neutral',
edge: 'control',
':hover': { fill: 'neutral-hover' },
},
},
[$.tone('danger')]: {
Root: {
fill: 'danger',
ink: 'on-danger',
':hover': { fill: 'danger-hover' },
},
},
[$.tone('quiet')]: {
Root: {
fill: 'none',
ink: 'default',
edge: 'none',
depth: 'none',
':hover': { fill: 'selected', ink: 'on-selected' },
},
},
[$.shape('icon')]: { Root: { size: 'square', padX: 0 } },
[$.disabled(true)]: { Root: { state: 'disabled' } },
}),
{ defaults: { tone: 'primary', shape: 'label', disabled: false } },
)import { createElements } from '@toned/react'
import { buttonStyles } from './sheets/button.ts'
const ButtonParts = createElements(buttonStyles)
export function Button({
tone,
shape,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
tone?: 'primary' | 'secondary' | 'danger' | 'quiet'
shape?: 'label' | 'icon'
}) {
return (
<ButtonParts tone={tone} shape={shape} disabled={props.disabled ?? false}>
<ButtonParts.Root as="button" type="button" {...props} />
</ButtonParts>
)
}2. The themes
Modern is the default theme. It supplies 118 values.
| Field | Modern |
|---|---|
| titleBar | none |
| statusBar | none |
| buttonOpen | none |
| buttonClose | none |
| bullet | none |
| cursor | none |
| blur | none |
| sheen | linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0)) |
| displayStyle | normal |
| displayTracking | -0.02em |
| smoothing | antialiased |
| iconRendering | auto |
| radiusAppBar | 0 |
| radiusTabTray | 0 |
| barInset | 0 |
| tabTrayPad | 0 |
| tabTrayAlign | stretch |
| shadowAppBar | none |
| shadowSelected | none |
| disabledOpacity | 0.45 |
| stage | linear-gradient(180deg, #f1f3f7 0%, #e6e9f0 100%) |
| surface | #f7f8fa |
| raised | #ffffff |
| sunken | #ffffff |
| overlay | #ffffff |
| ink | #1f2633 |
| inkStrong | #0d1220 |
| inkMuted | #5a6375 |
| accent | #4f46e5 |
| accentHover | #4338ca |
| onAccent | #ffffff |
| neutral | #ffffff |
| neutralHover | #f3f4f7 |
| onNeutral | #1f2633 |
| danger | #c9271b |
| dangerHover | #ab2016 |
| onDanger | #ffffff |
| selected | #eceefe |
| onSelected | #3730a3 |
| hover | #f3f4f7 |
| bar | #ffffff |
| onBar | #1f2633 |
| head | #fafbfc |
| onHead | #2a3140 |
| ok | #dcfce7 |
| onOk | #166534 |
| warn | #fef3c7 |
| onWarn | #92400e |
| bad | #fee2e2 |
| onBad | #991b1b |
| line | #e4e7ec |
| lineStrong | #d0d5dd |
| lineFilled | transparent |
| rule | #eaecf0 |
| cutout | #f7f8fa |
| track | #e4e7ec |
| meter | #4f46e5 |
| tray | #eef0f4 |
| thumb | #ffffff |
| onThumb | #0d1220 |
| knob | #ffffff |
| tabTray | transparent |
| tabSelected | transparent |
| onTabSelected | #0d1220 |
| tabIndicator | #4f46e5 |
| chart | #c7cbf9 |
| caret | #4f46e5 |
| scrim | rgba(15, 20, 35, 0.45) |
| fontBody | system-ui, -apple-system, "Segoe UI", Roboto, sans-serif |
| fontDisplay | system-ui, -apple-system, "Segoe UI", Roboto, sans-serif |
| fontData | system-ui, -apple-system, "Segoe UI", Roboto, sans-serif |
| sizeBody | 14px |
| sizeSmall | 12px |
| sizeTitle | 15px |
| sizeDisplay | 26px |
| weightBody | 400 |
| weightStrong | 600 |
| weightDisplay | 700 |
| leading | 1.5 |
| caps | none |
| labelCase | none |
| labelTracking | 0 |
| radiusControl | 8px |
| radiusInner | 6px |
| radiusTab | 0 |
| radiusPanel | 12px |
| radiusWindow | 14px |
| radiusBadge | 999px |
| radiusPill | 999px |
| radiusChart | 3px |
| panelRule | 1px |
| panelBorder | 1px |
| panelBorderStyle | solid |
| controlBorder | 1px |
| fieldRule | 1px |
| fieldBorder | 1px |
| controlBorderStyle | solid |
| ruleWidth | 1px |
| ruleStyle | solid |
| barRule | 1px |
| tabRule | 2px |
| meterHeight | 6px |
| focusRing | 2px solid #4f46e5 |
| focusOffset | 2px |
| shadowPanel | 0 1px 2px rgba(16, 24, 40, 0.05) |
| shadowControl | 0 1px 2px rgba(16, 24, 40, 0.05) |
| shadowButton | 0 1px 2px rgba(16, 24, 40, 0.08) |
| shadowField | 0 1px 2px rgba(16, 24, 40, 0.04) |
| shadowThumb | 0 1px 2px rgba(16, 24, 40, 0.14), 0 0 0 1px rgba(16, 24, 40, 0.04) |
| shadowKnob | 0 1px 3px rgba(16, 24, 40, 0.24) |
| shadowTabTray | inset 0 -1px 0 0 #e4e7ec |
| shadowOverlay | 0 0 0 1px rgba(16, 24, 40, 0.04), 0 4px 8px -2px rgba(16, 24, 40, 0.06), 0 16px 40px -6px rgba(16, 24, 40, 0.2) |
| shadowWindow | 0 1px 2px rgba(16, 24, 40, 0.06), 0 24px 48px -12px rgba(16, 24, 40, 0.16) |
| unit | 4px |
| controlHeight | 36px |
| duration | 150ms |
| easing | ease |
| press | scale(0.98) |
import type { Theme } from './theme.ts'
/**
* Six themes for one set of stylesheets. A theme is a plain object of values:
* it holds no selectors and names no component.
*/
const sans = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'
const mono =
'ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace'
/** What most themes leave off, or leave at the browser's default. */
const plain = {
titleBar: 'none',
statusBar: 'none',
buttonOpen: 'none',
buttonClose: 'none',
bullet: 'none',
cursor: 'none',
blur: 'none',
sheen: 'none',
displayStyle: 'normal',
displayTracking: '0',
smoothing: 'antialiased',
iconRendering: 'auto',
radiusAppBar: '0',
radiusTabTray: '0',
barInset: '0',
tabTrayPad: '0',
tabTrayAlign: 'stretch',
shadowAppBar: 'none',
shadowSelected: 'none',
disabledOpacity: '0.45',
} as const
const modern: Theme = {
...plain,
stage: 'linear-gradient(180deg, #f1f3f7 0%, #e6e9f0 100%)',
surface: '#f7f8fa',
raised: '#ffffff',
sunken: '#ffffff',
overlay: '#ffffff',
ink: '#1f2633',
inkStrong: '#0d1220',
inkMuted: '#5a6375',
accent: '#4f46e5',
accentHover: '#4338ca',
onAccent: '#ffffff',
neutral: '#ffffff',
neutralHover: '#f3f4f7',
onNeutral: '#1f2633',
danger: '#c9271b',
dangerHover: '#ab2016',
onDanger: '#ffffff',
sheen:
'linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0))',
selected: '#eceefe',
onSelected: '#3730a3',
hover: '#f3f4f7',
bar: '#ffffff',
onBar: '#1f2633',
head: '#fafbfc',
onHead: '#2a3140',
ok: '#dcfce7',
onOk: '#166534',
warn: '#fef3c7',
onWarn: '#92400e',
bad: '#fee2e2',
onBad: '#991b1b',
line: '#e4e7ec',
lineStrong: '#d0d5dd',
lineFilled: 'transparent',
rule: '#eaecf0',
cutout: '#f7f8fa',
track: '#e4e7ec',
meter: '#4f46e5',
tray: '#eef0f4',
thumb: '#ffffff',
onThumb: '#0d1220',
knob: '#ffffff',
tabTray: 'transparent',
tabSelected: 'transparent',
onTabSelected: '#0d1220',
tabIndicator: '#4f46e5',
chart: '#c7cbf9',
caret: '#4f46e5',
scrim: 'rgba(15, 20, 35, 0.45)',
fontBody: sans,
fontDisplay: sans,
fontData: sans,
sizeBody: '14px',
sizeSmall: '12px',
sizeTitle: '15px',
sizeDisplay: '26px',
weightBody: '400',
weightStrong: '600',
weightDisplay: '700',
leading: '1.5',
caps: 'none',
labelCase: 'none',
labelTracking: '0',
displayTracking: '-0.02em',
radiusControl: '8px',
radiusInner: '6px',
radiusTab: '0',
radiusPanel: '12px',
radiusWindow: '14px',
radiusBadge: '999px',
radiusPill: '999px',
radiusChart: '3px',
panelRule: '1px',
panelBorder: '1px',
panelBorderStyle: 'solid',
controlBorder: '1px',
fieldRule: '1px',
fieldBorder: '1px',
controlBorderStyle: 'solid',
ruleWidth: '1px',
ruleStyle: 'solid',
barRule: '1px',
tabRule: '2px',
meterHeight: '6px',
focusRing: '2px solid #4f46e5',
focusOffset: '2px',
shadowPanel: '0 1px 2px rgba(16, 24, 40, 0.05)',
shadowControl: '0 1px 2px rgba(16, 24, 40, 0.05)',
shadowButton: '0 1px 2px rgba(16, 24, 40, 0.08)',
shadowField: '0 1px 2px rgba(16, 24, 40, 0.04)',
shadowThumb:
'0 1px 2px rgba(16, 24, 40, 0.14), 0 0 0 1px rgba(16, 24, 40, 0.04)',
shadowKnob: '0 1px 3px rgba(16, 24, 40, 0.24)',
// Tabs are underlined: the rule under the list is this inset shadow.
shadowTabTray: 'inset 0 -1px 0 0 #e4e7ec',
shadowOverlay:
'0 0 0 1px rgba(16, 24, 40, 0.04), 0 4px 8px -2px rgba(16, 24, 40, 0.06), 0 16px 40px -6px rgba(16, 24, 40, 0.2)',
shadowWindow:
'0 1px 2px rgba(16, 24, 40, 0.06), 0 24px 48px -12px rgba(16, 24, 40, 0.16)',
unit: '4px',
controlHeight: '36px',
duration: '150ms',
easing: 'ease',
press: 'scale(0.98)',
}
/** A stepped outline drawn with four offset shadows, so corners stay notched. */
const pixels = (color: string) =>
`0 -4px 0 0 ${color}, 0 4px 0 0 ${color}, -4px 0 0 0 ${color}, 4px 0 0 0 ${color}`
const pixelFont = `Monaco, "Lucida Console", ${mono}`
/** Dark outline, lit top-left edge, shaded bottom-right edge. */
const pixelBevel = `${pixels('#0d0e17')}, inset -4px -4px 0 0 rgba(0, 0, 0, 0.25), inset 4px 4px 0 0 rgba(255, 255, 255, 0.35)`
// Colours are the 16 of the Sweetie 16 palette, plus a darker shade for
// shadows. Every length is a multiple of the 4px pixel.
const pixel: Theme = {
...plain,
stage: 'conic-gradient(#29366f 25%, transparent 0) 0 0 / 16px 16px, #1a1c2c',
surface: '#333c57',
raised: '#1a1c2c',
sunken: '#1a1c2c',
overlay: '#29366f',
ink: '#f4f4f4',
inkStrong: '#ffcd75',
inkMuted: '#a9c0cf',
accent: '#ffcd75',
accentHover: '#a7f070',
onAccent: '#1a1c2c',
neutral: '#94b0c2',
neutralHover: '#f4f4f4',
onNeutral: '#1a1c2c',
danger: '#b13e53',
dangerHover: '#ef7d57',
onDanger: '#f4f4f4',
selected: '#3b5dc9',
onSelected: '#f4f4f4',
hover: '#29366f',
bar: '#1a1c2c',
onBar: '#f4f4f4',
head: '#3b5dc9',
onHead: '#f4f4f4',
ok: '#38b764',
onOk: '#1a1c2c',
warn: '#ffcd75',
onWarn: '#1a1c2c',
bad: '#b13e53',
onBad: '#f4f4f4',
line: '#f4f4f4',
lineStrong: '#f4f4f4',
lineFilled: 'transparent',
rule: '#566c86',
cutout: '#333c57',
track: '#566c86',
meter:
'repeating-linear-gradient(90deg, #a7f070 0 12px, transparent 12px 16px)',
tray: '#1a1c2c',
thumb: '#3b5dc9',
onThumb: '#f4f4f4',
knob: '#f4f4f4',
tabTray: 'transparent',
tabSelected: '#3b5dc9',
onTabSelected: '#f4f4f4',
tabIndicator: 'transparent',
chart: '#3b5dc9',
caret: '#ffcd75',
scrim: 'rgba(26, 28, 44, 0.82)',
fontBody: pixelFont,
fontDisplay: pixelFont,
fontData: pixelFont,
sizeBody: '13px',
sizeSmall: '11px',
sizeTitle: '16px',
sizeDisplay: '24px',
weightBody: '700',
weightStrong: '700',
weightDisplay: '700',
leading: '1.5',
caps: 'uppercase',
labelCase: 'uppercase',
labelTracking: '0.06em',
smoothing: 'none',
iconRendering: 'crispEdges',
radiusControl: '0',
radiusInner: '0',
radiusTab: '0',
radiusPanel: '0',
radiusWindow: '0',
radiusBadge: '0',
radiusPill: '0',
radiusChart: '0',
// The outline is the stepped shadow below, not a border.
panelRule: '0',
panelBorder: '0',
panelBorderStyle: 'solid',
controlBorder: '0',
fieldRule: '4px',
fieldBorder: '4px',
controlBorderStyle: 'solid',
ruleWidth: '4px',
ruleStyle: 'solid',
barRule: '4px',
tabRule: '0',
meterHeight: '8px',
focusRing: '4px solid #73eff7',
focusOffset: '4px',
shadowPanel: `${pixels('#f4f4f4')}, 4px 8px 0 0 #0d0e17, 8px 4px 0 0 #0d0e17`,
shadowControl: pixelBevel,
shadowButton: pixelBevel,
shadowField: 'none',
shadowThumb: 'none',
shadowKnob: `${pixels('#0d0e17')}, inset -4px -4px 0 0 rgba(0, 0, 0, 0.25)`,
shadowTabTray: 'inset 0 -4px 0 0 #566c86',
shadowOverlay: `${pixels('#f4f4f4')}, 8px 12px 0 0 #0d0e17, 12px 8px 0 0 #0d0e17`,
shadowWindow: `${pixels('#f4f4f4')}, 8px 12px 0 0 #0d0e17, 12px 8px 0 0 #0d0e17`,
unit: '4px',
controlHeight: '40px',
duration: '160ms',
easing: 'steps(2, end)',
press: 'translate(4px, 4px)',
disabledOpacity: '0.5',
bullet: '"> "',
}
// Glass is two layers. The window is the content layer: frosted, mostly
// opaque, so text keeps its contrast. Bars, wells, thumbs, buttons and
// overlays float above it: each catches light on its top edge, casts a soft
// shadow and is rounded concentrically with the layer that holds it
// (window 28 → app bar 18 and panel 20 → control 12 → inner 9).
//
// No part has a border. A border sits outside the padding box, so a gradient
// fill repeats under it and a translucent one doubles up there. Each edge is
// an inset hairline instead, with a second, brighter one along the top.
const glassShade = (alpha: number) => `rgba(18, 38, 84, ${alpha})`
const glassLight = (alpha: number) => `rgba(255, 255, 255, ${alpha})`
/** A hairline of light inside the edge, brighter along the top. */
const glassEdge = (around: number, top: number) =>
`inset 0 0 0 1px ${glassLight(around)}, inset 0 1px 0 ${glassLight(top)}`
/** The hairline of a recessed part. */
const glassWell = `inset 0 0 0 1px ${glassShade(0.08)}`
const glassFont = (cut: string) =>
`-apple-system, BlinkMacSystemFont, "SF Pro ${cut}", "Helvetica Neue", system-ui, sans-serif`
const glass: Theme = {
...plain,
// One hue with a single warm glow, so the frosted window has light and
// shade to pick up without turning the page into a rainbow.
stage:
'radial-gradient(42% 46% at 14% 20%, #cfe6ff 0, rgba(207, 230, 255, 0) 70%), radial-gradient(40% 52% at 86% 16%, #4f7df0 0, rgba(79, 125, 240, 0) 72%), radial-gradient(48% 54% at 74% 96%, #ffc9a6 0, rgba(255, 201, 166, 0) 70%), radial-gradient(42% 50% at 6% 92%, #2c63cf 0, rgba(44, 99, 207, 0) 72%), linear-gradient(160deg, #86b4f4 0%, #5683e2 55%, #8d94ea 100%)',
surface: `linear-gradient(180deg, ${glassLight(0.76)} 0%, ${glassLight(0.6)} 100%)`,
raised: glassLight(0.5),
sunken: glassLight(0.58),
// Nearly opaque: an overlay sits over text, and must stay legible where
// the browser cannot blur what is behind it.
overlay: `linear-gradient(180deg, ${glassLight(0.97)} 0%, rgba(244, 248, 255, 0.95) 100%)`,
ink: '#0e1a33',
inkStrong: '#06102a',
inkMuted: 'rgba(14, 26, 51, 0.74)',
accent: '#0453c4',
accentHover: '#085cd2',
onAccent: '#ffffff',
neutral: `linear-gradient(180deg, ${glassLight(0.94)} 0%, ${glassLight(0.74)} 100%)`,
neutralHover: glassLight(0.96),
onNeutral: '#0e1a33',
danger: '#c21d37',
dangerHover: '#cc2540',
onDanger: '#ffffff',
// Light falling off from the top of a tinted control, shade along its foot.
sheen:
'linear-gradient(180deg, rgba(255, 255, 255, 0.18) 0%, rgba(255, 255, 255, 0) 62%, rgba(0, 8, 40, 0.06) 100%)',
selected: glassLight(0.74),
onSelected: '#0a3f96',
hover: glassLight(0.4),
bar: glassLight(0.46),
onBar: '#0e1a33',
head: glassLight(0.3),
onHead: '#0e1a33',
ok: 'rgba(36, 178, 92, 0.2)',
onOk: '#0a5226',
warn: 'rgba(240, 160, 0, 0.24)',
onWarn: '#6a4200',
bad: 'rgba(235, 60, 70, 0.18)',
onBad: '#8a1422',
line: glassLight(0.5),
lineStrong: glassLight(0.5),
lineFilled: 'transparent',
rule: 'rgba(14, 26, 51, 0.1)',
cutout: glassLight(0.92),
track: 'rgba(14, 26, 51, 0.13)',
meter: 'linear-gradient(90deg, #3f8cff 0%, #0453c4 100%)',
tray: 'rgba(14, 26, 51, 0.07)',
thumb: glassLight(0.94),
onThumb: '#06102a',
knob: '#ffffff',
tabTray: 'rgba(14, 26, 51, 0.07)',
tabSelected: glassLight(0.94),
onTabSelected: '#06102a',
tabIndicator: 'transparent',
chart: 'rgba(5, 88, 204, 0.3)',
caret: '#0453c4',
scrim: 'rgba(12, 26, 60, 0.3)',
fontBody: glassFont('Text'),
fontDisplay: glassFont('Display'),
fontData: glassFont('Text'),
sizeBody: '15px',
sizeSmall: '13px',
sizeTitle: '17px',
sizeDisplay: '30px',
weightBody: '400',
weightStrong: '600',
weightDisplay: '700',
leading: '1.45',
caps: 'none',
labelCase: 'none',
labelTracking: '0',
displayTracking: '-0.022em',
radiusControl: '12px',
radiusInner: '9px',
radiusTab: '9px',
radiusTabTray: '13px',
radiusPanel: '20px',
radiusWindow: '28px',
radiusAppBar: '18px',
radiusBadge: '999px',
radiusPill: '999px',
radiusChart: '4px',
// Edges are the inset hairlines in the shadows below, not borders.
panelRule: '0',
panelBorder: '0',
panelBorderStyle: 'solid',
controlBorder: '0',
fieldRule: '0',
fieldBorder: '0',
controlBorderStyle: 'solid',
ruleWidth: '1px',
ruleStyle: 'solid',
barRule: '0',
tabRule: '0',
meterHeight: '6px',
focusRing: '3px solid #0453c4',
focusOffset: '2px',
shadowPanel: `${glassEdge(0.5, 0.9)}, 0 1px 2px ${glassShade(0.05)}, 0 8px 24px -12px ${glassShade(0.18)}`,
// Wells are recessed: a dark hairline, shade under the top lip.
shadowControl: `${glassWell}, inset 0 1px 2px ${glassShade(0.12)}`,
// One shadow for every tone: the lit edge reads on a tinted button, the
// cast shadow outlines a white one.
shadowButton: `${glassEdge(0.2, 0.5)}, 0 1px 2px ${glassShade(0.2)}, 0 6px 14px -6px ${glassShade(0.3)}`,
shadowField: `${glassWell}, inset 0 1px 2px ${glassShade(0.08)}`,
shadowThumb: `${glassEdge(0.6, 1)}, 0 0 0 0.5px ${glassShade(0.1)}, 0 1px 2px ${glassShade(0.14)}, 0 3px 8px -3px ${glassShade(0.24)}`,
shadowKnob: `0 0 0 0.5px ${glassShade(0.16)}, 0 1px 2px ${glassShade(0.2)}, 0 4px 10px -2px ${glassShade(0.28)}`,
// Inset only: the lists that hold a selected item scroll, and would clip
// a shadow cast outside it.
shadowSelected: `${glassEdge(0.6, 1)}, inset 0 -1px 0 ${glassShade(0.06)}`,
shadowAppBar: `${glassEdge(0.6, 1)}, 0 1px 2px ${glassShade(0.06)}, 0 10px 26px -12px ${glassShade(0.3)}`,
shadowTabTray: `${glassWell}, inset 0 1px 2px ${glassShade(0.08)}`,
shadowOverlay: `${glassEdge(0.7, 1)}, 0 0 0 0.5px ${glassShade(0.12)}, 0 2px 6px ${glassShade(0.08)}, 0 28px 64px -16px rgba(12, 30, 80, 0.45)`,
shadowWindow: `${glassEdge(0.45, 0.95)}, 0 2px 4px rgba(12, 30, 80, 0.08), 0 36px 80px -24px rgba(12, 30, 80, 0.5)`,
blur: 'blur(28px) saturate(170%)',
unit: '5px',
controlHeight: '40px',
barInset: '10px',
tabTrayPad: '4px',
tabTrayAlign: 'flex-start',
// Overshoots, then settles: a control springs back when released.
duration: '320ms',
easing: 'cubic-bezier(0.3, 1.4, 0.5, 1)',
press: 'scale(0.96)',
}
// Text mode: one size on a character grid, the 16 CGA colours, double-line
// frames, a block shadow one cell behind each window, and no motion.
const dos: Theme = {
...plain,
stage: 'repeating-conic-gradient(#0000aa 0 25%, #000055 0 50%) 0 0 / 4px 4px',
surface: '#0000aa',
raised: '#0000aa',
sunken: '#000000',
overlay: '#0000aa',
ink: '#aaaaaa',
inkStrong: '#ffffff',
inkMuted: '#55ffff',
accent: '#ffff55',
accentHover: '#ffffff',
onAccent: '#000000',
neutral: '#aaaaaa',
neutralHover: '#ffffff',
onNeutral: '#000000',
danger: '#aa0000',
dangerHover: '#ff5555',
onDanger: '#ffffff',
selected: '#00aaaa',
onSelected: '#000000',
hover: '#000055',
bar: '#aaaaaa',
onBar: '#000000',
head: 'transparent',
onHead: '#ffff55',
ok: '#00aa00',
onOk: '#000000',
warn: '#aa5500',
onWarn: '#ffff55',
bad: '#aa0000',
onBad: '#ffffff',
line: '#aaaaaa',
lineStrong: '#aaaaaa',
lineFilled: 'transparent',
rule: '#aaaaaa',
cutout: '#0000aa',
// Two-pixel dither: the shaded block character of a text-mode gauge.
track: 'repeating-conic-gradient(#aaaaaa 0 25%, #0000aa 0 50%) 0 0 / 2px 2px',
meter: '#ffff55',
tray: '#000000',
thumb: '#00aaaa',
onThumb: '#000000',
knob: '#ffffff',
tabTray: 'transparent',
tabSelected: '#00aaaa',
onTabSelected: '#000000',
tabIndicator: 'transparent',
chart: '#00aaaa',
caret: '#ffff55',
scrim: 'rgba(0, 0, 0, 0.6)',
fontBody: mono,
fontDisplay: mono,
fontData: mono,
// Text mode has one size; emphasis comes from colour.
sizeBody: '16px',
sizeSmall: '16px',
sizeTitle: '16px',
sizeDisplay: '16px',
weightBody: '400',
weightStrong: '400',
weightDisplay: '400',
leading: '1.25',
caps: 'none',
labelCase: 'none',
labelTracking: '0',
smoothing: 'none',
iconRendering: 'crispEdges',
radiusControl: '0',
radiusInner: '0',
radiusTab: '0',
radiusPanel: '0',
radiusWindow: '0',
radiusBadge: '0',
radiusPill: '0',
radiusChart: '0',
panelRule: '3px',
panelBorder: '3px',
panelBorderStyle: 'double',
controlBorder: '0',
fieldRule: '0',
fieldBorder: '0',
controlBorderStyle: 'solid',
ruleWidth: '1px',
ruleStyle: 'dashed',
barRule: '0',
tabRule: '0',
meterHeight: '10px',
focusRing: '2px solid #ffff55',
focusOffset: '1px',
shadowPanel: 'none',
shadowControl: 'none',
shadowButton: '8px 6px 0 0 #000000',
shadowField: 'none',
shadowThumb: 'none',
shadowKnob: 'none',
shadowTabTray: 'inset 0 -1px 0 0 #aaaaaa',
shadowOverlay: '16px 14px 0 0 #000000',
shadowWindow: '16px 14px 0 0 rgba(0, 0, 0, 0.6)',
unit: '0.5ch',
controlHeight: '28px',
duration: '0s',
easing: 'linear',
press: 'translate(2px, 2px)',
disabledOpacity: '0.5',
titleBar: 'flex',
statusBar: 'flex',
buttonOpen: '"[ "',
buttonClose: '" ]"',
bullet: '"\\25BA "',
cursor: '"\\2588"',
}
const paperInk = '#1f1b16'
const paperSerif =
'"Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif'
// A printed page: ink on cream, serif type, rules instead of boxes, and
// labels set in spaced capitals.
const paper: Theme = {
...plain,
stage:
'repeating-linear-gradient(transparent 0 31px, rgba(31, 27, 22, 0.06) 31px 32px), #e8dfc8',
surface: '#fbf7ec',
raised: '#fbf7ec',
sunken: '#f3ecda',
overlay: '#fffdf6',
ink: paperInk,
inkStrong: '#000000',
inkMuted: '#5f574a',
accent: paperInk,
accentHover: '#3d362c',
onAccent: '#fbf7ec',
neutral: '#fbf7ec',
neutralHover: '#efe6cc',
onNeutral: paperInk,
danger: '#8f2a1d',
dangerHover: '#6f1f15',
onDanger: '#fbf7ec',
selected: '#efe6cc',
onSelected: paperInk,
hover: '#f3ecda',
bar: '#fbf7ec',
onBar: paperInk,
head: 'transparent',
onHead: paperInk,
ok: '#e0ead2',
onOk: '#2a5a2d',
warn: '#f4e3b5',
onWarn: '#6b4a00',
bad: '#f3d6cc',
onBad: '#7c1f14',
line: paperInk,
lineStrong: paperInk,
lineFilled: paperInk,
rule: 'rgba(31, 27, 22, 0.55)',
cutout: '#fbf7ec',
track: '#e3dac2',
meter: paperInk,
tray: 'transparent',
thumb: paperInk,
onThumb: '#fbf7ec',
knob: '#fbf7ec',
tabTray: 'transparent',
tabSelected: 'transparent',
onTabSelected: '#000000',
tabIndicator: paperInk,
chart: '#c9bea3',
caret: '#8f2a1d',
scrim: 'rgba(31, 27, 22, 0.45)',
fontBody: paperSerif,
fontDisplay:
'Didot, "Bodoni 72", "Bodoni MT", "Playfair Display", Georgia, serif',
fontData: paperSerif,
sizeBody: '16px',
sizeSmall: '12px',
sizeTitle: '21px',
sizeDisplay: '40px',
weightBody: '400',
weightStrong: '700',
weightDisplay: '400',
leading: '1.5',
caps: 'none',
labelCase: 'uppercase',
labelTracking: '0.1em',
displayStyle: 'italic',
radiusControl: '0',
radiusInner: '0',
radiusTab: '0',
radiusPanel: '0',
radiusWindow: '0',
radiusBadge: '2px',
radiusPill: '999px',
radiusChart: '0',
// Ruled on one edge: a double line above a panel, a single line under a field.
panelRule: '3px',
panelBorder: '0',
panelBorderStyle: 'double',
controlBorder: '1px',
fieldRule: '1px',
fieldBorder: '0',
controlBorderStyle: 'solid',
ruleWidth: '1px',
ruleStyle: 'dotted',
barRule: '1px',
tabRule: '2px',
meterHeight: '4px',
focusRing: '2px solid #8f2a1d',
focusOffset: '3px',
shadowPanel: 'none',
shadowControl: 'none',
shadowButton: 'none',
shadowField: 'none',
shadowThumb: 'none',
shadowKnob: 'none',
shadowTabTray: `inset 0 -1px 0 0 ${paperInk}`,
shadowOverlay: '0 18px 50px rgba(60, 40, 10, 0.35)',
shadowWindow:
'0 1px 2px rgba(60, 40, 10, 0.25), 0 14px 34px rgba(60, 40, 10, 0.18)',
unit: '5px',
controlHeight: '36px',
duration: '120ms',
easing: 'ease',
press: 'none',
}
// One border weight (3px) and two hard offsets: 4px for controls, 8px for
// everything that holds them.
const brutalist: Theme = {
...plain,
stage: '#ffe600',
surface: '#ffffff',
raised: '#ffffff',
sunken: '#ffffff',
overlay: '#ffffff',
ink: '#000000',
inkStrong: '#000000',
inkMuted: '#3d3d3d',
accent: '#0038ff',
accentHover: '#0026b3',
onAccent: '#ffffff',
neutral: '#ffffff',
neutralHover: '#ffe600',
onNeutral: '#000000',
danger: '#ff3b30',
dangerHover: '#ff7a72',
onDanger: '#000000',
selected: '#000000',
onSelected: '#ffe600',
hover: '#ffe600',
bar: '#000000',
onBar: '#ffffff',
head: '#000000',
onHead: '#ffffff',
ok: '#00d26a',
onOk: '#000000',
warn: '#ffe600',
onWarn: '#000000',
bad: '#ff3b30',
onBad: '#000000',
line: '#000000',
lineStrong: '#000000',
lineFilled: '#000000',
rule: '#000000',
cutout: '#ffffff',
track: '#d4d4d4',
meter: '#0038ff',
tray: '#ffffff',
thumb: '#000000',
onThumb: '#ffe600',
knob: '#ffffff',
tabTray: 'transparent',
tabSelected: '#000000',
onTabSelected: '#ffe600',
tabIndicator: 'transparent',
chart: '#000000',
caret: '#0038ff',
scrim: 'rgba(255, 230, 0, 0.72)',
fontBody: '"Helvetica Neue", Helvetica, Arial, sans-serif',
fontDisplay: '"Arial Black", "Helvetica Neue", Helvetica, Arial, sans-serif',
fontData: '"Helvetica Neue", Helvetica, Arial, sans-serif',
sizeBody: '15px',
sizeSmall: '12px',
sizeTitle: '18px',
sizeDisplay: '32px',
weightBody: '500',
weightStrong: '800',
weightDisplay: '900',
leading: '1.35',
caps: 'uppercase',
labelCase: 'uppercase',
labelTracking: '0.04em',
displayTracking: '-0.02em',
radiusControl: '0',
radiusInner: '0',
radiusTab: '0',
radiusPanel: '0',
radiusWindow: '0',
radiusBadge: '0',
radiusPill: '0',
radiusChart: '0',
panelRule: '3px',
panelBorder: '3px',
panelBorderStyle: 'solid',
controlBorder: '3px',
fieldRule: '3px',
fieldBorder: '3px',
controlBorderStyle: 'solid',
ruleWidth: '3px',
ruleStyle: 'solid',
barRule: '3px',
tabRule: '0',
meterHeight: '12px',
focusRing: '4px solid #ff3b30',
focusOffset: '3px',
shadowPanel: '8px 8px 0 0 #000000',
shadowControl: 'none',
shadowButton: '4px 4px 0 0 #000000',
shadowField: 'none',
shadowThumb: 'none',
shadowKnob: 'none',
shadowTabTray: 'inset 0 -3px 0 0 #000000',
shadowOverlay: '8px 8px 0 0 #000000',
shadowWindow: '8px 8px 0 0 #000000',
unit: '5px',
controlHeight: '42px',
duration: '0s',
easing: 'linear',
press: 'translate(4px, 4px)',
}
/** Theme id → values. The first is the default, and the one the server renders. */
export const themes = { modern, pixel, glass, dos, paper, brutalist }
export type ThemeName = keyof typeof themes
export const defaultTheme: ThemeName = 'modern'
/** What the switcher shows for each theme. */
export const themeList: readonly {
id: ThemeName
label: string
note: string
}[] = [
{
id: 'modern',
label: 'Modern',
note: 'Soft neutrals, underlined tabs, rounded corners, light shadows.',
},
{
id: 'pixel',
label: '8-bit',
note: 'A 4px grid, stepped outlines, block shadows, a 16-colour palette, stepped motion.',
},
{
id: 'glass',
label: 'Liquid glass',
note: 'A frosted window over a colour field, floating controls with lit edges, concentric radii, springy motion.',
},
{
id: 'dos',
label: 'DOS',
note: 'Text mode: one size, the CGA palette, double-line frames, block shadows, no motion.',
},
{
id: 'paper',
label: 'Paper',
note: 'Serif type, ink on cream, rules instead of boxes.',
},
{
id: 'brutalist',
label: 'Brutalist',
note: 'One heavy border weight, hard offset shadows, flat primaries.',
},
]3. The theme schema and the system
The type every theme satisfies, and the system that checks the themes against it and emits them as CSS.
/**
* What a theme has to supply. Tokens read these fields and nothing else, so a
* theme that satisfies this type can restyle every stylesheet in the showcase.
*
* Every value is a CSS value: on the web each field becomes one custom
* property, and the generated classes refer to it.
*/
export type Theme = {
// ── Colour ────────────────────────────────────────────────────────────
/** Behind the window. A full `background`, so it may be a gradient. */
stage: string
/** The window. A full `background`. */
surface: string
raised: string
sunken: string
/** Menus, dialogs and toasts. */
overlay: string
ink: string
inkStrong: string
inkMuted: string
accent: string
accentHover: string
onAccent: string
neutral: string
neutralHover: string
onNeutral: string
danger: string
dangerHover: string
onDanger: string
/** A `background-image` laid over `accent` and `danger`: light on a filled control. */
sheen: string
selected: string
onSelected: string
/** A row or quiet control under the pointer. */
hover: string
bar: string
onBar: string
head: string
onHead: string
ok: string
onOk: string
warn: string
onWarn: string
bad: string
onBad: string
/** The border of a panel. */
line: string
/** The border of a control. */
lineStrong: string
/** The border of a control that is filled with `accent` or `danger`. */
lineFilled: string
/** A divider between rows or columns. */
rule: string
/** The ring that separates overlapping avatars. */
cutout: string
track: string
/** The filled part of a progress bar. A full `background`. */
meter: string
/** The well of a segmented control, and its selected option. */
tray: string
thumb: string
onThumb: string
/** The handle of a switch and of a slider. */
knob: string
/** The well behind the tabs, and the selected tab. */
tabTray: string
tabSelected: string
onTabSelected: string
/** The line under the selected tab, for themes that underline it. */
tabIndicator: string
/** Chart bars other than the newest one, which takes `accent`. */
chart: string
caret: string
scrim: string
// ── Type ──────────────────────────────────────────────────────────────
fontBody: string
fontDisplay: string
fontData: string
sizeBody: string
sizeSmall: string
sizeTitle: string
sizeDisplay: string
weightBody: string
weightStrong: string
weightDisplay: string
leading: string
/** `text-transform` for titles and controls. */
caps: string
labelCase: string
labelTracking: string
displayStyle: string
displayTracking: string
smoothing: string
// ── Shape ─────────────────────────────────────────────────────────────
radiusControl: string
/** A part nested inside a control: a segment in its tray, a tab in its well. */
radiusInner: string
radiusTab: string
radiusTabTray: string
radiusPanel: string
radiusWindow: string
radiusAppBar: string
radiusBadge: string
radiusPill: string
radiusChart: string
/** A panel's top edge, and its other three: a theme may rule one edge only. */
panelRule: string
panelBorder: string
panelBorderStyle: string
controlBorder: string
/** A field's bottom edge, and its other three. */
fieldRule: string
fieldBorder: string
controlBorderStyle: string
ruleWidth: string
ruleStyle: string
/** The line under the app bar. */
barRule: string
/** The thickness of the line under the selected tab. */
tabRule: string
meterHeight: string
/** A complete `outline` for keyboard focus. */
focusRing: string
focusOffset: string
// ── Depth ─────────────────────────────────────────────────────────────
shadowPanel: string
/** A checkbox, a switch track. */
shadowControl: string
shadowButton: string
shadowField: string
shadowThumb: string
shadowKnob: string
/** The current navigation item, project and tab. */
shadowSelected: string
shadowAppBar: string
/** The well behind the tabs; themes that underline tabs draw the rule here. */
shadowTabTray: string
shadowOverlay: string
/** The frame around the whole interface. */
shadowWindow: string
/** `backdrop-filter` for the window and for overlays. */
blur: string
// ── Density ───────────────────────────────────────────────────────────
/** One spacing step; `gap: 3` is three of these. */
unit: string
controlHeight: string
/** The space around the app bar: a theme may float it inside the window. */
barInset: string
tabTrayPad: string
/** `align-self` for the tab list: across the column, or as wide as its tabs. */
tabTrayAlign: string
// ── Motion ────────────────────────────────────────────────────────────
duration: string
easing: string
/** `transform` while a control is held down. */
press: string
disabledOpacity: string
// ── Structure ─────────────────────────────────────────────────────────
/** `display` for the window's title and status bars. */
titleBar: string
statusBar: string
/** `content` around a button label and before the selected list item. */
buttonOpen: string
buttonClose: string
bullet: string
/** `content` after the page title: a text-mode cursor. */
cursor: string
/** `shape-rendering` for icons: a pixel theme turns smoothing off. */
iconRendering: 'auto' | 'crispEdges'
}import { defineSystem } from '@toned/core'
import { themes } from './themes.ts'
import * as tokens from './tokens.ts'
/**
* The showcase's own system. `themes` is checked against the schema the
* tokens were written for: a theme that omits a field, or adds one no token
* reads, does not compile.
*
* The build writes each declared theme as CSS custom properties: the first
* on `:root`, and every one under `[data-theme='<name>']`. Generated classes
* read those variables, so switching theme changes that one attribute.
*/
export const themeSystem = defineSystem({
id: 'themes',
tokens,
conditions: { media: { md: 768, lg: 1080 } },
themes,
})
export const { stylesheet } = themeSystem4. The tokens
Each resolver turns a role into styles by reading the theme. Structural differences are tokens too: a theme decides whether the window has a title bar.
import { defineToken, defineTokenFor, type WebInlineStyle } from '@toned/core'
import type { Theme } from './theme.ts'
import { sliderValue } from './vars.ts'
/**
* The showcase's vocabulary. Stylesheets name a role (`fill: 'raised'`,
* `edge: 'panel'`); each resolver reads the active theme to decide what that
* role looks like. `defineTokenFor<Theme>()` limits a resolver to fields the
* theme schema declares.
*/
const token = defineTokenFor<Theme, WebInlineStyle>()
/** Rules for pseudo-elements, keyed by the selector suffix. */
type PseudoRules = Record<string, Record<string, string | number>>
const roles = <const Map extends Record<string, unknown>>(map: Map) =>
Object.keys(map) as Array<keyof Map & string>
/** `n` spacing steps of the theme's unit. */
const steps = (theme: Theme, n: number) => `calc(${theme.unit} * ${n})`
const scale = [0, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 8] as const
// ── Colour ──────────────────────────────────────────────────────────────
const fills = {
stage: 'stage',
surface: 'surface',
raised: 'raised',
sunken: 'sunken',
overlay: 'overlay',
accent: 'accent',
'accent-hover': 'accentHover',
neutral: 'neutral',
'neutral-hover': 'neutralHover',
danger: 'danger',
'danger-hover': 'dangerHover',
selected: 'selected',
hover: 'hover',
bar: 'bar',
head: 'head',
ok: 'ok',
warn: 'warn',
bad: 'bad',
track: 'track',
meter: 'meter',
tray: 'tray',
thumb: 'thumb',
knob: 'knob',
'tab-tray': 'tabTray',
'tab-selected': 'tabSelected',
chart: 'chart',
ink: 'ink',
} as const satisfies Record<string, keyof Theme>
/** Fills the theme may light: its `sheen` is laid over the colour. */
const lit: readonly string[] = [
'accent',
'accent-hover',
'danger',
'danger-hover',
]
/** Background, by role. */
export const fill = token({
values: [...roles(fills), 'none'],
resolve: (value, theme) => ({
background:
value === 'none'
? 'transparent'
: lit.includes(value)
? `${theme.sheen}, ${theme[fills[value]]}`
: theme[fills[value]],
}),
})
const inks = {
default: 'ink',
strong: 'inkStrong',
muted: 'inkMuted',
accent: 'accent',
'on-accent': 'onAccent',
'on-neutral': 'onNeutral',
danger: 'danger',
'on-danger': 'onDanger',
'on-selected': 'onSelected',
'on-thumb': 'onThumb',
'on-tab-selected': 'onTabSelected',
'on-bar': 'onBar',
'on-head': 'onHead',
'on-ok': 'onOk',
'on-warn': 'onWarn',
'on-bad': 'onBad',
surface: 'surface',
} as const satisfies Record<string, keyof Theme>
/** Text and icon colour, by role. */
export const ink = token({
values: roles(inks),
resolve: (value, theme) => ({ color: theme[inks[value]] }),
})
// ── Shape ───────────────────────────────────────────────────────────────
/** Which outline a part carries. The theme chooses width, style and colour. */
export const edge = token({
values: [
'panel',
'control',
'filled',
'field',
'rule',
'rule-top',
'side',
'bar',
'tab',
'tab-selected',
'cutout',
'none',
],
resolve: (value, theme) => {
if (value === 'panel')
return {
borderTopWidth: theme.panelRule,
borderRightWidth: theme.panelBorder,
borderBottomWidth: theme.panelBorder,
borderLeftWidth: theme.panelBorder,
borderStyle: theme.panelBorderStyle,
borderColor: theme.line,
}
if (value === 'field')
return {
borderTopWidth: theme.fieldBorder,
borderRightWidth: theme.fieldBorder,
borderBottomWidth: theme.fieldRule,
borderLeftWidth: theme.fieldBorder,
borderStyle: theme.controlBorderStyle,
borderColor: theme.lineStrong,
}
if (value === 'control' || value === 'filled')
return {
borderWidth: theme.controlBorder,
borderStyle: theme.controlBorderStyle,
borderColor: value === 'filled' ? theme.lineFilled : theme.lineStrong,
}
// A divider under or over a row, or beside a column.
if (value === 'rule' || value === 'rule-top' || value === 'side')
return {
borderWidth:
value === 'rule'
? `0 0 ${theme.ruleWidth} 0`
: value === 'rule-top'
? `${theme.ruleWidth} 0 0 0`
: `0 ${theme.ruleWidth} 0 0`,
borderStyle: theme.ruleStyle,
borderColor: theme.rule,
}
if (value === 'bar')
return {
borderWidth: `0 0 ${theme.barRule} 0`,
borderStyle: theme.ruleStyle,
borderColor: theme.rule,
}
// The line under a tab. Every tab reserves it, so selecting moves nothing.
if (value === 'tab' || value === 'tab-selected')
return {
borderWidth: `0 0 ${theme.tabRule} 0`,
borderStyle: 'solid',
borderColor:
value === 'tab-selected' ? theme.tabIndicator : 'transparent',
}
// Separates overlapping avatars from each other.
if (value === 'cutout')
return {
borderWidth: '2px',
borderStyle: 'solid',
borderColor: theme.cutout,
}
return { borderWidth: 0, borderStyle: 'none', borderColor: 'transparent' }
},
})
const corners = {
control: 'radiusControl',
'tab-tray': 'radiusTabTray',
inner: 'radiusInner',
tab: 'radiusTab',
panel: 'radiusPanel',
window: 'radiusWindow',
'app-bar': 'radiusAppBar',
badge: 'radiusBadge',
pill: 'radiusPill',
chart: 'radiusChart',
} as const satisfies Record<string, keyof Theme>
export const corner = token({
values: [...roles(corners), 'none'],
resolve: (value, theme) => ({
borderRadius: value === 'none' ? 0 : theme[corners[value]],
}),
})
const shadows = {
panel: 'shadowPanel',
control: 'shadowControl',
button: 'shadowButton',
field: 'shadowField',
thumb: 'shadowThumb',
knob: 'shadowKnob',
selected: 'shadowSelected',
'app-bar': 'shadowAppBar',
'tab-tray': 'shadowTabTray',
overlay: 'shadowOverlay',
window: 'shadowWindow',
} as const satisfies Record<string, keyof Theme>
export const depth = token({
values: [...roles(shadows), 'none'],
resolve: (value, theme) => ({
boxShadow: value === 'none' ? 'none' : theme[shadows[value]],
}),
})
/** What a translucent surface does to whatever is behind it. */
export const frost = token({
values: ['surface'],
resolve: (_value, theme) => ({
backdropFilter: theme.blur,
WebkitBackdropFilter: theme.blur,
}),
})
// ── Type ────────────────────────────────────────────────────────────────
export const type = token({
values: ['body', 'small', 'label', 'control', 'title', 'display', 'data'],
resolve: (value, theme) => {
if (value === 'small') return { fontSize: theme.sizeSmall }
if (value === 'label')
return {
fontSize: theme.sizeSmall,
fontWeight: theme.weightStrong,
textTransform: theme.labelCase,
letterSpacing: theme.labelTracking,
}
if (value === 'control')
return {
fontFamily: theme.fontBody,
fontSize: theme.sizeBody,
fontWeight: theme.weightStrong,
lineHeight: theme.leading,
textTransform: theme.caps,
}
if (value === 'title')
return {
fontFamily: theme.fontDisplay,
fontSize: theme.sizeTitle,
fontWeight: theme.weightStrong,
textTransform: theme.caps,
lineHeight: 1.25,
}
if (value === 'display')
return {
fontFamily: theme.fontDisplay,
fontSize: theme.sizeDisplay,
fontWeight: theme.weightDisplay,
fontStyle: theme.displayStyle,
letterSpacing: theme.displayTracking,
textTransform: theme.caps,
lineHeight: 1.1,
}
if (value === 'data')
return {
fontFamily: theme.fontData,
fontVariantNumeric: 'tabular-nums',
}
return {
fontFamily: theme.fontBody,
fontSize: theme.sizeBody,
fontWeight: theme.weightBody,
lineHeight: theme.leading,
WebkitFontSmoothing: theme.smoothing,
}
},
})
export const weight = token({
values: ['strong'],
resolve: (_value, theme) => ({ fontWeight: theme.weightStrong }),
})
// ── Density ─────────────────────────────────────────────────────────────
export const gap = token({
values: scale,
resolve: (value, theme) => ({ gap: steps(theme, value) }),
})
export const pad = token({
values: scale,
resolve: (value, theme) => ({ padding: steps(theme, value) }),
})
export const padX = token({
values: scale,
resolve: (value, theme) => ({ paddingInline: steps(theme, value) }),
})
export const padY = token({
values: scale,
resolve: (value, theme) => ({ paddingBlock: steps(theme, value) }),
})
/** Fixed sizes that follow the theme's control height or spacing unit. */
export const size = token({
values: [
'control',
'square',
'avatar',
'box',
'switch',
'thumb',
'meter',
'swatch',
'spark',
],
resolve: (value, theme) => {
const square = (side: string) => ({
width: side,
height: side,
flexShrink: 0,
})
if (value === 'control') return { minHeight: theme.controlHeight }
// At least square: a theme may still draw brackets around the icon.
if (value === 'square')
return {
minWidth: theme.controlHeight,
height: theme.controlHeight,
flexShrink: 0,
}
if (value === 'avatar') return square(steps(theme, 7))
if (value === 'box') return square(steps(theme, 4.5))
if (value === 'thumb') return square(steps(theme, 4))
if (value === 'swatch') return square(steps(theme, 3))
if (value === 'switch')
return { width: steps(theme, 10), height: steps(theme, 6), flexShrink: 0 }
// A small chart beside a figure.
if (value === 'spark')
return { width: steps(theme, 18), height: steps(theme, 8), minWidth: 0 }
return { height: theme.meterHeight }
},
})
/** Room a theme leaves around a part, or inside it. */
export const inset = token({
values: ['app-bar', 'tab-tray'],
resolve: (value, theme) =>
value === 'app-bar'
? { margin: theme.barInset }
: { padding: theme.tabTrayPad, alignSelf: theme.tabTrayAlign },
})
/** Pulls an avatar over the one before it. */
export const overlap = token({
values: ['avatar'],
resolve: (_value, theme) => ({ marginInlineStart: steps(theme, -1) }),
})
// ── Motion ──────────────────────────────────────────────────────────────
export const motion = token({
values: ['themed'],
resolve: (_value, theme) => ({
transitionProperty:
'background, color, border-color, box-shadow, transform, opacity',
transitionDuration: theme.duration,
transitionTimingFunction: theme.easing,
}),
})
/** How a control moves while it is held down. */
export const press = token({
values: ['down'],
resolve: (_value, theme) => ({ transform: theme.press }),
})
/** Moves a switch's thumb to its on position. */
export const slide = token({
values: ['on', 'off'],
resolve: (value, theme) => ({
transform: value === 'on' ? `translateX(${steps(theme, 4)})` : 'none',
}),
})
export const focus = token({
// `inside` is for a part in a scrolling row, which would clip a ring around it.
values: ['ring', 'inside'],
resolve: (value, theme) => ({
outline: theme.focusRing,
outlineOffset:
value === 'inside'
? `calc(${theme.focusOffset} * -1 - 2px)`
: theme.focusOffset,
}),
})
/** A control that cannot be used right now. */
export const state = token({
values: ['disabled'],
resolve: (_value, theme) => ({
opacity: theme.disabledOpacity,
pointerEvents: 'none',
}),
})
/** The text cursor of a field. */
export const caret = token({
values: ['themed'],
resolve: (_value, theme) => ({ caretColor: theme.caret }),
})
/** How icons are drawn. It is inherited, so the stage sets it once. */
export const icons = token({
values: ['themed'],
resolve: (_value, theme) => ({ shapeRendering: theme.iconRendering }),
})
// ── Structure ───────────────────────────────────────────────────────────
/**
* Parts that exist in some themes only. The theme supplies the `display`
* value, so a stylesheet can declare a title bar without knowing which themes
* draw one.
*/
export const chrome = token({
values: ['title-bar', 'status-bar'],
resolve: (value, theme) => ({
display: value === 'title-bar' ? theme.titleBar : theme.statusBar,
}),
})
/**
* Text a theme draws around a part: `[ Save ]`, a marker on the selection,
* or a cursor after the page title.
*/
export const affix = token({
values: ['button', 'bullet', 'cursor'],
resolve: () => ({}),
pseudoRules: (value, theme): PseudoRules =>
value === 'button'
? {
'::before': { content: theme.buttonOpen },
'::after': { content: theme.buttonClose },
}
: value === 'bullet'
? { '::before': { content: theme.bullet } }
: { '::after': { content: theme.cursor, color: theme.accent } },
})
const sliderTrack = (theme: Theme) => ({
height: theme.meterHeight,
// The part up to the thumb takes the accent; the rest is the track.
background: `linear-gradient(${theme.accent}, ${theme.accent}) 0 0 / var(${sliderValue.inToken}, 0%) 100% no-repeat, ${theme.track}`,
borderRadius: theme.radiusPill,
})
const sliderThumb = (theme: Theme) => ({
appearance: 'none',
boxSizing: 'border-box',
width: steps(theme, 4.5),
height: steps(theme, 4.5),
background: theme.knob,
borderWidth: theme.controlBorder,
borderStyle: theme.controlBorderStyle,
borderColor: theme.lineStrong,
borderRadius: theme.radiusPill,
boxShadow: theme.shadowKnob,
})
/**
* Surfaces the browser draws and no element can stand in for: the parts of a
* range input, the dialog backdrop and the text selection.
*/
export const native = token({
values: ['slider', 'dialog', 'selection'],
resolve: (value) =>
value === 'slider' ? { appearance: 'none', background: 'transparent' } : {},
pseudoRules: (value, theme): PseudoRules => {
if (value === 'slider')
return {
'::-webkit-slider-runnable-track': sliderTrack(theme),
'::-moz-range-track': sliderTrack(theme),
'::-webkit-slider-thumb': {
...sliderThumb(theme),
// Centres the thumb on the thinner track.
marginTop: `calc((${theme.meterHeight} - ${steps(theme, 4.5)}) / 2)`,
},
'::-moz-range-thumb': sliderThumb(theme),
}
if (value === 'dialog') return { '::backdrop': { background: theme.scrim } }
return {
' ::selection': { background: theme.ink, color: theme.surface },
}
},
})
// ── Layout ──────────────────────────────────────────────────────────────
// Arrangement does not change with the theme, so these read no theme fields.
const flows = {
row: { display: 'flex', flexDirection: 'row' },
column: { display: 'flex', flexDirection: 'column' },
wrap: { display: 'flex', flexDirection: 'row', flexWrap: 'wrap' },
inline: { display: 'inline-flex', flexDirection: 'row' },
block: { display: 'block' },
cell: { display: 'table-cell' },
hidden: { display: 'none' },
} as const
export const flow = defineToken({
values: roles(flows),
resolve: (value) => flows[value],
})
const alignments = {
start: 'flex-start',
center: 'center',
end: 'flex-end',
stretch: 'stretch',
baseline: 'baseline',
} as const
export const align = defineToken({
values: roles(alignments),
resolve: (value) => ({ alignItems: alignments[value] }),
})
const justifications = {
start: 'flex-start',
center: 'center',
end: 'flex-end',
between: 'space-between',
} as const
export const justify = defineToken({
values: roles(justifications),
resolve: (value) => ({ justifyContent: justifications[value] }),
})
const flexes = {
// Takes the remaining room, and may shrink below its content.
fill: { flexGrow: 1, flexShrink: 1, flexBasis: '0%', minWidth: 0 },
fixed: { flexGrow: 0, flexShrink: 0 },
full: { width: '100%' },
tall: { height: '100%' },
} as const
export const flex = defineToken({
values: roles(flexes),
resolve: (value) => flexes[value],
})
const grids = {
stats: 'repeat(auto-fit, minmax(190px, 1fr))',
fields: 'repeat(auto-fit, minmax(200px, 1fr))',
chart: 'repeat(12, minmax(0, 1fr))',
} as const
export const tracks = defineToken({
values: roles(grids),
resolve: (value) => ({
display: 'grid',
gridTemplateColumns: grids[value],
}),
})
const measures = {
window: 1120,
sidebar: 208,
dialog: 440,
menu: 200,
chart: 120,
meter: 64,
percent: '4ch',
} as const
export const measure = defineToken({
values: roles(measures),
resolve: (value) =>
value === 'chart'
? { height: measures.chart }
: value === 'window'
? { width: '100%', maxWidth: measures.window }
: value === 'meter' || value === 'percent'
? { minWidth: measures[value] }
: value === 'dialog'
? { width: measures.dialog, maxWidth: 'calc(100vw - 32px)' }
: value === 'menu'
? { minWidth: measures.menu }
: { width: measures.sidebar, flexShrink: 0 },
})
const placements = {
origin: { position: 'relative' },
menu: { position: 'absolute', top: '100%', right: 0, zIndex: 3 },
// A select's arrow, over the control's trailing edge.
chevron: {
position: 'absolute',
right: 10,
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'none',
},
} as const
/** Where a part sits relative to the part that owns it. */
export const place = defineToken({
values: roles(placements),
resolve: (value) => placements[value],
})
const overflows = {
clip: { overflow: 'hidden' },
'scroll-x': { overflowX: 'auto' },
truncate: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
nowrap: { whiteSpace: 'nowrap' },
} as const
export const overflow = defineToken({
values: roles(overflows),
resolve: (value) => overflows[value],
})
export const textAlign = defineToken({
values: ['left', 'center', 'right'] as const,
resolve: (value) => ({ textAlign: value }),
})
/** Resets a native control so the tokens above decide how it looks. */
export const reset = defineToken({
values: ['control', 'table', 'dialog', 'list'] as const,
resolve: (value) =>
value === 'control'
? { appearance: 'none', margin: 0, cursor: 'pointer' }
: value === 'table'
? { borderCollapse: 'collapse', width: '100%' }
: value === 'dialog'
? { margin: 'auto', maxHeight: 'calc(100vh - 32px)' }
: { listStyle: 'none', margin: 0 },
})