glass-ui-react documentation
A React component library built on one material: translucent glass that lights up when it's on. Things you press are raised; things you pour text into are recessed. That one rule is what makes 36 components read as a single object.
src/styles/liquid.css) and use the exact markup the React
components render, so what you see is the real material — not a screenshot.
Start
Install, render, own the state.
Installation
One package, one stylesheet. React 18 or newer is a peer dependency — the library ships ESM and CJS builds plus type declarations.
npm install glass-ui-react
Import the stylesheet exactly once, at the root of your app:
import 'glass-ui-react/styles.css';
| Entry point | Path | Notes |
|---|---|---|
| import | ./dist/index.js | ESM build |
| require | ./dist/index.cjs | CommonJS build |
| types | ./dist/index.d.ts | Bundled declarations |
| ./styles.css | ./dist/styles/liquid.css | The single stylesheet |
Quick start
ThemeProvider writes data-theme onto <html>;
ToastProvider mounts the toast viewport. Both are optional, but wrapping
the app in them once is what everything else assumes.
import { ThemeProvider, ToastProvider, ModeRack, ModeToggle } from 'glass-ui-react';
import 'glass-ui-react/styles.css';
export function App() {
return (
<ThemeProvider defaultTheme="dark">
<ToastProvider>
<ModeRack>
<ModeToggle name="Sleep" accent="sleep" onLabel="Dims at 10:30" />
<ModeToggle name="Do not disturb" accent="dnd" onLabel="Calls silenced" />
<ModeToggle name="Personal" accent="personal" onLabel="Work apps hidden" />
</ModeRack>
</ToastProvider>
</ThemeProvider>
);
}
Controlled state
Every stateful component works both ways. Leave the value off and the component keeps
its own; pass one and you own it. Internally that is useControllableState —
passing undefined for the value is what makes a component uncontrolled,
so don't flip between the two at runtime.
// uncontrolled — the component remembers
<Switch label="Dim the screen" defaultChecked />
// controlled — you remember
<Switch label="Dim the screen" checked={on} onCheckedChange={setOn} />
| Pattern | Value prop | Default prop | Change handler |
|---|---|---|---|
| Checked | checked | defaultChecked | onCheckedChange(boolean) |
| Pressed | pressed | defaultPressed | onPressedChange(boolean) |
| Value | value | defaultValue | onValueChange(value) |
| Open | open | defaultOpen | onOpenChange(boolean) |
Theming & tokens
The stylesheet is driven entirely by custom properties. Override them anywhere in the
cascade — on :root, on a subtree, or inline on a single component.
Dark mode is data-theme="dark" on any ancestor.
:root {
--sleep: #3E7BFA; /* the three accents */
--dnd: #FF3428;
--personal: #FFA51F;
--blur: 22px; /* how frosted the glass is */
--panel: rgba(255, 255, 255, .88);
}
| Token | Light | Dark | What it drives |
|---|---|---|---|
| --sleep | #3E7BFA | #5B95FF | Blue accent, the default |
| --dnd | #FF3428 | #FF4033 | Red accent, danger |
| --personal | #FFA51F | #FFB23D | Amber accent, warning |
| --ok | #22A06B | #3ED9A0 | Success tone |
| --accent | var(--sleep) | Per-component accent; what accent sets | |
| --bg-1 / --bg-2 | #EFEDE9 / #DAD7D1 | #1A1A1C / #070708 | Page gradient |
| --ink / --ink-soft | #101012 | #F2F1EF | Text, and its muted pair |
| --hair | 14–16% ink | Hairline rules and borders | |
| --pill-top/mid/bot | translucent whites | The raised-glass gradient | |
| --well | 5% ink | 30% black | Recessed surfaces (inputs, tracks) |
| --panel | 88% white | 88% #141418 | Menus, popovers, calendars |
| --panel-free | 58% white | 60% #141418 | Dialogs and toasts |
| --blur / --sat | 22px / 1.7 | 24px / 1.5 | backdrop-filter strength |
| --gloss / --cast | whites / shadows | Inner highlight, outer shadow | |
| --glow-a / --glow-b | .55 / .22 | .85 / .42 | Halo and bloom intensity when lit |
| --r | 999px | The pill radius | |
| --ease | cubic-bezier(.2,.9,.24,1) | Every transition curve | |
Per component, pass accent="sleep" | "dnd" | "personal", or set
--accent yourself. A few components take a raw CSS colour instead —
accentColor on Slider, Select,
Progress, Avatar and Dialog.
A note on glass
Card is translucent but not
blurred — so the menus and popovers inside it can frost what they cover. If you add
blur to your own containers, expect popovers inside them to stop frosting.
The same stacking rule is why Card accepts a lifted class:
each blurred card is its own stacking context, so a popover escaping upward needs the
whole card raised rather than just the panel.
Accessibility
Native elements wherever one exists — and where none exists, the ARIA pattern in full.
prefers-reduced-motion is respected throughout: every animation and
transition collapses to nothing.
| Component | What it does |
|---|---|
| Switch / Checkbox | Real <input type="checkbox">, so keyboard and form submission come free. role="switch" on the former, indeterminate on the latter. |
| RadioGroup | Real radios in a role="radiogroup"; arrow keys are the browser's. |
| Dialog / AlertDialog | Wraps <dialog> — focus trapping, inertness and Escape are the platform's job. AlertDialog adds role="alertdialog". |
| Tabs | Roving tab index; Arrow Left/Right moves selection and focus together. |
| Select / DropdownMenu | Arrow keys walk the list, Enter picks, Escape closes and restores focus to the trigger. |
| DatePicker | Arrows by day, Up/Down by week, PageUp/PageDown by month, Enter to pick. aria-live on the month title. |
| DataTable | aria-sort on exactly one column; a tri-state header checkbox that goes indeterminate when a page is partly selected. |
| IconButton | aria-label is a required prop — an icon button has no visible text to name it. |
| Tooltip | Pure CSS on hover and focus, wired to the child with aria-describedby. |
Theme
The provider, the hook, and the light behind the glass.
ThemeProvider context
Holds the current theme and writes it to data-theme on an element —
<html> unless you point it somewhere else. It renders no markup of
its own.
Live — this whole page is the preview
<ThemeProvider defaultTheme="dark">
<App />
</ThemeProvider>
| Prop | Type | Default | Notes |
|---|---|---|---|
| children * | ReactNode | — | Your app. |
| defaultTheme | 'light' | 'dark' | 'light' | Initial theme. Not reactive — use setTheme after mount. |
| target | HTMLElement | null | document.documentElement | The element that carries data-theme. |
useTheme hook
Returns { theme, setTheme }. Throws if called outside a
ThemeProvider — that is deliberate, a silent no-op theme toggle is worse
than a stack trace.
const { theme, setTheme } = useTheme();
<Button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? 'Lights on' : 'Lights off'}
</Button>
| Returns | Type | Notes |
|---|---|---|
| theme | 'light' | 'dark' | The current theme. |
| setTheme | (theme: Theme) => void | Switches it; the provider rewrites the attribute in an effect. |
AmbientLight
Three drifting orbs behind the page, so the glass has something to refract. Takes no
props, renders aria-hidden, and sits at z-index:0 behind
everything — it is already running behind this page.
<ThemeProvider>
<AmbientLight />
<App />
</ThemeProvider>
--orb (.12 light, .17 dark), so dialling the whole effect down is a
one-line override.
Actions
Things you press. All of them raised.
Button forwardRef → button
A raised slab of glass. Extends every native button attribute, and defaults
type to "button" so it never submits a form by accident.
Preview
<Button>Default</Button>
<Button variant="primary" onClick={save}>Save changes</Button>
<Button variant="danger">Delete</Button>
| Prop | Type | Default | Notes |
|---|---|---|---|
| variant | 'default' | 'primary' | 'danger' | 'default' | primary lights up in the accent colour, danger in red. |
| type | 'button' | 'submit' | 'reset' | 'button' | Overridden deliberately — set it back to submit inside a form. |
| className | string | — | Appended after push and the variant class. |
| …rest | ButtonHTMLAttributes | — | Everything native, spread onto the element. |
IconButton forwardRef → button
A 52px circle for a single 24×24 glyph. aria-label is required by the
type, because an icon button has no visible text to name it. Pass
pressed to make it a toggle — it lights red.
Preview — the last two toggle
<IconButton aria-label="Search"><SearchIcon /></IconButton>
<IconButton
aria-label="Mute"
pressed={muted}
onClick={() => setMuted(!muted)}
>
<MuteIcon />
</IconButton>
| Prop | Type | Default | Notes |
|---|---|---|---|
| aria-label * | string | — | Required by the type. There is no visible label to fall back on. |
| pressed | boolean | undefined | Sets aria-pressed. Leave it off for a plain action button — undefined omits the attribute entirely. |
| type | string | 'button' | Same default as Button. |
| …rest | ButtonHTMLAttributes | — | Spread onto the element. |
IconButton is not stateful — pressed is display only. Own the
boolean yourself, or reach for Toggle, which manages it.
Toggle forwardRef → button
A square 38px pill that stays pressed. Controlled or not, like everything else. Pass
wide when the label is text rather than an icon.
Preview — click any of them
<Toggle aria-label="Bold" defaultPressed><BoldIcon /></Toggle>
<Toggle wide accent="dnd" pressed={live} onPressedChange={setLive}>
Live
</Toggle>
| Prop | Type | Default | Notes |
|---|---|---|---|
| pressed | boolean | — | Pass to control it. |
| defaultPressed | boolean | false | Uncontrolled starting state. |
| onPressedChange | (pressed: boolean) => void | — | Fires before your own onClick. |
| accent | 'sleep' | 'dnd' | 'personal' | 'sleep' | The colour it lights up in. |
| wide | boolean | false | Auto-width pill for a text label instead of a 38px square. |
| …rest | ButtonHTMLAttributes | — | Minus onChange. |
ToggleGroup
A row of toggles that share a value array. type="multiple" behaves like a
set of switches; type="single" like a segmented choice that can also be
empty — clicking the selected item clears it.
Preview — multiple (left) and single (right)
<ToggleGroup
aria-label="Text style"
items={[
{ value: 'bold', label: <B />, 'aria-label': 'Bold' },
{ value: 'italic', label: <I />, 'aria-label': 'Italic' },
]}
defaultValue={['bold']}
onValueChange={setStyles}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| items * | ToggleGroupItem[] | — | { value, label, 'aria-label'? } |
| type | 'single' | 'multiple' | 'multiple' | single keeps at most one pressed, and allows none. |
| value | string[] | — | Always an array, even for single. |
| defaultValue | string[] | [] | Uncontrolled starting selection. |
| onValueChange | (value: string[]) => void | — | Called with the whole next array. |
| accent | Accent | 'sleep' | Applied to every item. |
| wide | boolean | false | Applied to every item. |
| aria-label * | string | — | Names the role="group" wrapper. |
Selection
Picking one thing, or several, or a number.
Switch forwardRef → input
A native checkbox with role="switch" in a glass track. The whole row is
the label, so the hit target is the full width — and keyboard, focus and form
submission are the browser's.
Preview — all three accents
<Switch label="Dim the screen" defaultChecked />
<Switch
label="Silence calls"
hint="Until 8am"
accent="dnd"
checked={quiet}
onCheckedChange={setQuiet}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| label * | ReactNode | — | The row's text. The whole row is clickable. |
| hint | ReactNode | — | Secondary text on the far side of the row. |
| accent | Accent | 'sleep' | Colour of the lit track. |
| checked | boolean | — | Pass to control it. |
| defaultChecked | boolean | false | Uncontrolled starting state. |
| onCheckedChange | (checked: boolean) => void | — | Fires on the input's change. |
| disabled | boolean | false | Native disabled. |
| name / value | string | — | For form submission. |
| className | string | — | Appended to the label wrapper. |
Checkbox forwardRef → input
The same row, with a 23px rounded box that draws its tick on. Adds
indeterminate — neither on nor off, the parent of a partly ticked group.
That is a DOM property rather than an attribute, so the component sets it in an effect.
Preview — the third is indeterminate
<Checkbox label="Wi-Fi" defaultChecked />
<Checkbox
label="All radios"
checked={all}
indeterminate={some && !all}
onCheckedChange={setAll}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| indeterminate | boolean | false | Shows a dash instead of a tick. Visual and assistive only — the underlying checked is untouched. |
Everything else is identical to Switch: label, hint, accent, checked, defaultChecked, onCheckedChange, disabled, name, value, className. | |||
RadioGroup
Real radios sharing a name, wrapped in role="radiogroup". If you leave
name off, one is generated with useId, so two groups on a
page never collide. Arrow-key navigation is the browser's own.
Preview
<RadioGroup
aria-label="Sleep schedule"
options={[
{ value: 'nightly', label: 'Every night' },
{ value: 'weekdays', label: 'Weeknights', hint: 'Mon–Fri' },
{ value: 'never', label: 'Never' },
]}
value={schedule}
onValueChange={setSchedule}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| options * | RadioOption[] | — | { value, label, hint?, disabled? } |
| value | string | — | Pass to control it. |
| defaultValue | string | first option's value | Falls back to '' if the list is empty. |
| onValueChange | (value: string) => void | — | — |
| name | string | generated | Set it for form submission. |
| accent | Accent | 'sleep' | Applied to every row. |
| aria-label * | string | — | Names the group. |
Segmented
One choice out of a few, with a thumb that slides to the selection. It is measured in pixels on mount and on resize, so the thumb tracks the real button widths rather than an assumed percentage.
Preview — click to slide the thumb
<Segmented
aria-label="Range"
options={[
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
]}
onValueChange={setRange}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| options * | { value: string; label: ReactNode }[] | — | No hint here — segments are one word each. |
| value | string | — | Pass to control it. |
| defaultValue | string | first option's value | — |
| onValueChange | (value: string) => void | — | — |
| aria-label * | string | — | Names the role="tablist". |
Select
A value, not an action — that is what separates this from DropdownMenu.
The listbox staggers its options in on open (each row is delayed by its index), arrow
keys walk them, Enter picks, and Escape closes and returns focus to the trigger.
Preview — click the trigger
<Select
aria-label="Wake-up sound"
accentColor="var(--personal)"
options={[
{ value: 'early', label: 'Early bird', meta: '6:00' },
{ value: 'standard', label: 'Standard', meta: '7:30' },
]}
value={sound}
onValueChange={setSound}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| options * | SelectOption[] | — | { value, label, meta? } — meta is right-aligned detail. |
| value | string | — | Pass to control it. |
| defaultValue | string | first option's value | — |
| onValueChange | (value: string) => void | — | — |
| accentColor | string | var(--sleep) | Any CSS colour; drives the swatch, the ring and the selected dot. |
| disabled | boolean | false | Disables the trigger. |
| aria-label * | string | — | Names both the trigger and the listbox. |
Popover if you need to own it.
Slider
A native range input on a glass track. The fill spans the whole width and is revealed by a moving mask, so the colour ramp stays put instead of rescaling on every change. The mask head is placed in pixels at the thumb's true centre — a native thumb travels from half its width to width minus half, not 0–100%.
Preview — drag either one
<Slider aria-label="Brightness" defaultValue={62} />
<Slider
aria-label="Volume"
accentColor="var(--sleep)"
min={0} max={11} step={0.5}
value={volume}
onValueChange={setVolume}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| value | number | — | Pass to control it. |
| defaultValue | number | 50 | — |
| onValueChange | (value: number) => void | — | Already coerced to a number. |
| min / max / step | number | 0 / 100 / 1 | Passed straight to the input. |
| accentColor | string | var(--personal) | Sets --sl: the fill and its trailing bloom. |
| disabled | boolean | false | — |
| aria-label * | string | — | Names the range input. |
Text
Recessed, because you pour text into them.
Input forwardRef → input
A recessed well with optional leading icon, trailing unit, hint line and clear button.
The clear button only appears when the field has a value — which the component reads
from value or defaultValue, so it needs one of them to know.
Preview — plain, with icon and unit, and invalid
<Input placeholder="Search settings" />
<Input
icon={<ClockIcon />}
suffix="min"
hint="How long before the screen dims"
value={delay}
onChange={(e) => setDelay(e.target.value)}
onClear={() => setDelay('')}
/>
<Input invalid hint="That doesn't look like an address" value={email} />
| Prop | Type | Default | Notes |
|---|---|---|---|
| icon | ReactNode | — | Leading glyph, usually a 24×24 svg. Tints to the accent on focus. |
| suffix | ReactNode | — | Text after the field, e.g. a unit. |
| hint | ReactNode | — | Shown under the field; turns red when invalid. |
| invalid | boolean | false | Red ring and aria-invalid. |
| onClear | () => void | — | Renders the clear button. You clear the value yourself. |
| accent | Accent | 'sleep' | Focus ring colour. |
| wrapperClassName | string | — | Goes on the .field wrapper; className goes on the input itself. |
| …rest | InputHTMLAttributes | — | Minus prefix, which collides with the HTML attribute. |
Textarea forwardRef → textarea
The same well, taller and with a softer radius. Adds showCount, which
renders a live count against maxLength — both are needed for the counter
to appear. icon, suffix and onClear are accepted
by the type but deliberately not rendered.
Preview
<Textarea
maxLength={140}
showCount
hint="Shown on the mode's card"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| showCount | boolean | false | Live count against maxLength. Needs maxLength set. |
| hint / invalid / accent | — | — | Same as Input. |
| wrapperClassName | string | — | Same split as Input. |
| …rest | TextareaHTMLAttributes | — | Resize is off; the well has a 76px min-height. |
Disclosure
Showing one thing at a time.
Tabs
Full roving tab index: only the selected tab is in the tab order, and Arrow Left/Right
moves selection and focus together, wrapping at both ends. The ink bar is measured from
the active button, so it fits labels of any width. Panels stay mounted and are hidden
with the hidden attribute.
Preview — try the arrow keys
<Tabs
aria-label="Mode detail"
items={[
{ value: 'schedule', label: 'Schedule', content: <Schedule /> },
{ value: 'people', label: 'People', content: <People /> },
]}
onValueChange={setTab}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| items * | TabItem[] | — | { value, label, content } |
| value | string | — | Pass to control it. |
| defaultValue | string | first item's value | — |
| onValueChange | (value: string) => void | — | Fires on click and on arrow keys. |
| aria-label * | string | — | Names the tablist. |
| className | string | — | Wraps list and panels together. |
Accordion
Rows that expand by animating grid-template-rows from 0fr to
1fr — so the panel animates to its real height with no measuring and no
fixed max-height. type="single" closes the others;
type="multiple" lets them stack. Either way the value is an array.
Preview — single, so opening one closes the rest
At 22:30 every night, unless you've set a schedule of your own.
Favourites ring through. A second call from the same number within three minutes always rings.
Across every device signed in to the same account.
<Accordion
type="single"
defaultValue={['start']}
items={[
{ value: 'start', title: 'When does it start?', content: <p>At 22:30.</p> },
{ value: 'who', title: 'Who can still reach me?', content: <p>Favourites.</p> },
]}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| items * | AccordionItemData[] | — | { value, title, content } |
| type | 'single' | 'multiple' | 'single' | single closes the others. |
| value | string[] | — | The open items. An array even for single. |
| defaultValue | string[] | [] | Everything closed to start. |
| onValueChange | (value: string[]) => void | — | — |
Collapsible
One region and one trigger, with the trigger below the content — it reads as
"show more" rather than a header. Same 0fr → 1fr animation as
Accordion.
Preview
Sleep dims the display, silences notifications, and switches the lock screen to a clock. Alarms still sound.
<Collapsible label="More details" openLabel="Fewer details">
<p>Sleep dims the display and silences notifications.</p>
</Collapsible>
| Prop | Type | Default | Notes |
|---|---|---|---|
| label * | ReactNode | — | Trigger text when closed. |
| openLabel | ReactNode | label | Replaces it when open. |
| open | boolean | — | Pass to control it. |
| defaultOpen | boolean | false | — |
| onOpenChange | (open: boolean) => void | — | — |
| children * | ReactNode | — | The region, wired with aria-controls. |
Overlays
Everything that floats above the page.
Popover
The one floating surface everything else is built on. It owns open state, outside-click and Escape dismissal, and nothing else — no positioning library, since every panel here is anchored to its own trigger. The trigger is a render prop, so you decide what the anchor looks like while the component wires the ARIA.
Preview — click outside or press Escape to dismiss
Wind down
Sleep starts 30 minutes before your bedtime and eases the screen down.
align="end"
The panel's right edge lines up with the anchor's, so it can't run off screen.
<Popover
aria-label="Schedule"
trigger={(props) => (
<Button {...props}>{props.open ? 'Close' : 'Schedule'}</Button>
)}
>
<h4>Wind down</h4>
<p>Sleep starts 30 minutes before your bedtime.</p>
</Popover>
| Prop | Type | Default | Notes |
|---|---|---|---|
| trigger * | (props) => ReactNode | — | Called with ref, onClick, aria-expanded, aria-haspopup and open. Spread the first four onto a button. |
| children * | ReactNode | — | Panel contents. Always mounted; visibility is CSS. |
| open | boolean | — | Pass to control it. |
| defaultOpen | boolean | false | — |
| onOpenChange | (open: boolean) => void | — | Fires on click, outside click and Escape. |
| role | 'dialog' | 'menu' | 'listbox' | 'dialog' | Sets both the panel's role and the trigger's aria-haspopup. |
| align | 'start' | 'end' | 'start' | end aligns the panel's right edge with the anchor's. |
| className | string | — | On the anchor; panelClassName is on the panel. |
ref in the
render props is for. Forward it or focus will land on <body>.
DropdownMenu
Actions, not values. Built on Popover with role="menu": the
first enabled item takes focus when it opens, arrow keys walk the list and wrap, and
Escape closes it. Shortcut hints are decorative — the component does not bind them.
Preview — open it, then use ↑ ↓
<DropdownMenu
label="Actions"
aria-label="Mode actions"
items={[
{ label: 'New mode', shortcut: '⌘N', onSelect: create },
{ label: 'Duplicate', shortcut: '⌘D', onSelect: duplicate },
{ label: 'Share', disabled: true },
{ label: 'Delete', danger: true, separatorBefore: true, onSelect: remove },
]}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| items * | MenuItem[] | — | { label, onSelect?, icon?, shortcut?, danger?, separatorBefore?, disabled? } |
| label * | ReactNode | — | The trigger's text. |
| open / defaultOpen / onOpenChange | — | false | Same contract as Popover. |
| align | 'start' | 'end' | 'start' | Forwarded to Popover. |
| triggerClassName | string | — | Appended after push on the trigger. |
onSelect but does not close the menu —
close it from the handler if that is what you want. Keeping it open is what makes
multi-step menus possible.
Tooltip
Pure CSS on hover and focus, so it costs nothing until someone looks at it. It clones
its child to attach aria-describedby, which means the child must be a
single element that accepts props — not a fragment, not a string.
Preview — hover or tab to the buttons
<Tooltip label="Copy link">
<IconButton aria-label="Copy link"><LinkIcon /></IconButton>
</Tooltip>
| Prop | Type | Default | Notes |
|---|---|---|---|
| label * | ReactNode | — | Bubble contents. It does not wrap — keep it short. |
| children * | ReactElement | — | Exactly one element. Cloned to receive aria-describedby. |
IconButton still needs its own
aria-label — the tooltip does not replace it.
HoverCard
A rich panel on hover, with open and close delays so it doesn't flicker as the pointer
crosses it. Focus opens it too, since hover alone is unreachable by keyboard. State is
internal — there is no open prop.
Preview — hover the name
<HoverCard aria-label="Ada Lovelace" trigger="Ada Lovelace">
<Avatar name="Ada Lovelace" status="online" />
<p>Owns the glass material and the three accents.</p>
</HoverCard>
| Prop | Type | Default | Notes |
|---|---|---|---|
| trigger * | ReactNode | — | Rendered inside a button with a dashed underline. |
| children * | ReactNode | — | Panel contents. |
| openDelay | number | 170 | Milliseconds before it appears. |
| closeDelay | number | 140 | Milliseconds before it leaves. |
| aria-label | string | — | Names the panel's role="dialog". |
Dialog
Wraps the native <dialog>, so focus trapping, inertness and Escape are
the platform's job rather than ours. Open state is fully yours — there is no
defaultOpen. Clicking the backdrop calls onClose; so does
Escape, whose default cancel is prevented so React stays the source of truth.
Preview
const [open, setOpen] = useState(false);
<Dialog
open={open}
onClose={() => setOpen(false)}
icon={<MoonIcon />}
title="Set a bedtime"
description="Sleep starts 30 minutes before, easing the screen down."
footer={<>
<Button onClick={() => setOpen(false)}>Not now</Button>
<Button variant="primary" onClick={save}>Save</Button>
</>}
>
<Input icon={<ClockIcon />} defaultValue="22:30" />
</Dialog>
| Prop | Type | Default | Notes |
|---|---|---|---|
| open * | boolean | — | Controlled only. An effect calls showModal() / close(). |
| onClose * | () => void | — | Backdrop click and Escape. |
| title * | ReactNode | — | Serif heading. |
| description | ReactNode | — | Muted paragraph under it. |
| icon | ReactNode | — | Small glass chip above the title, tinted with the accent. |
| children | ReactNode | — | Wrapped in a .fields grid. |
| footer | ReactNode | — | Buttons along the bottom; they stretch to fill the row. |
| accentColor | string | var(--sleep) | Any CSS colour; drives the halo and the icon chip. |
AlertDialog
A dialog that interrupts: exactly two ways out, and no dismiss-by-accident — clicking
the backdrop does nothing. Escape still cancels, because the platform guarantees it.
destructive defaults to true, which paints the confirm button
red and the sheet's halo with it.
Preview
<AlertDialog
open={confirming}
onCancel={() => setConfirming(false)}
onConfirm={remove}
title="Delete this mode?"
description="Its schedule and app list go with it. This can't be undone."
confirmLabel="Delete"
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| open * | boolean | — | Controlled only. |
| onCancel * | () => void | — | Cancel button and Escape. |
| onConfirm * | () => void | — | Does not close the dialog for you. |
| title * | ReactNode | — | Wired as aria-labelledby. |
| description | ReactNode | — | — |
| icon | ReactNode | — | Chip above the title. |
| cancelLabel | string | 'Cancel' | — |
| confirmLabel | string | 'Confirm' | — |
| destructive | boolean | true | false makes the confirm button primary blue instead of red. |
ToastProvider & useToast context
Mount the provider once near the root; call useToast() anywhere below it.
The viewport is fixed bottom-right with aria-live="polite", and older
toasts drop off once max are on screen. A toast clears itself after its
duration — pass 0 to keep it until dismissed.
Preview — all four tones
const { toast, dismiss } = useToast();
toast({
title: 'Sleep scheduled',
description: 'Starts at 22:30 tonight.',
tone: 'success',
action: { label: 'Undo', onClick: undo },
});
| ToastProvider prop | Type | Default | Notes |
|---|---|---|---|
| children * | ReactNode | — | Your tree. |
| max | number | 3 | Older toasts drop off the top once this many are on screen. |
| defaultDuration | number | 4500 | Milliseconds, used when a toast doesn't set its own. |
| ToastOptions | Type | Default | Notes |
|---|---|---|---|
| title * | string | — | — |
| description | string | — | — |
| tone | 'info' | 'success' | 'warning' | 'danger' | 'info' | Maps to --sleep, --ok, --personal, --dnd. |
| duration | number | defaultDuration | 0 keeps it until dismissed. |
| action | { label, onClick } | — | Clicking it runs the handler and dismisses the toast. |
| useToast returns | Type | Notes |
|---|---|---|
| toast | (options: ToastOptions) => number | Returns the id, so you can dismiss it early. |
| dismiss | (id: number) => void | Removes it immediately. |
ToastProvider. The countdown bar along the
bottom edge is driven by --ms, so it always matches the real duration.
Display
Surfaces and ornaments. Mostly stateless.
Card forwardRef → div
The container everything else sits in. Its title is a small-caps label
along the top edge — not the HTML title attribute, which is why the props
omit it from the native set.
Preview
Tonight
Status
<Card title="Tonight">
<p>Bedtime 22:30</p>
</Card>
| Prop | Type | Default | Notes |
|---|---|---|---|
| title | ReactNode | — | Rendered as an <h2> in small caps. The native title attribute is omitted from the type to make room for it. |
| children | ReactNode | — | — |
| …rest | HTMLAttributes<HTMLDivElement> | — | — |
className="lifted" when a popover inside the card needs to escape above
its neighbours.
Badge
A small glass pill with an optional glowing dot. color is any CSS colour
and drives both the dot and its bloom.
Preview
<Badge>No dot</Badge>
<Badge color="var(--ok)">Online</Badge>
| Prop | Type | Default | Notes |
|---|---|---|---|
| color | string | — | Sets --bc. Omit it and no dot renders. |
| …rest | HTMLAttributes<HTMLSpanElement> | — | — |
Avatar
Shows the photo when it loads and the initials when it doesn't — an onError
on the image flips it to the fallback, so a broken URL degrades instead of showing a
torn-image icon. Initials are the first letter of the first two words of
name.
Preview — the third has a deliberately broken src
<Avatar name="Ada Lovelace" />
<Avatar name="Grace Hopper" src={photo} status="online" accentColor="var(--personal)" />
| Prop | Type | Default | Notes |
|---|---|---|---|
| name * | string | — | The alt text, and the source of the fallback initials. |
| src | string | — | Falls back to initials on error. |
| status | 'online' | 'none' | 'none' | Green dot at the bottom-right. |
| accentColor | string | var(--sleep) | The gradient behind the initials. |
| …rest | HTMLAttributes<HTMLSpanElement> | — | — |
AvatarStack
Overlapping avatars with a ring in the page colour between them. Anyone past
max collapses into a +N chip.
Preview — five people, max 3
<AvatarStack
max={3}
people={[
{ name: 'Ada Lovelace' },
{ name: 'Grace Hopper', accentColor: 'var(--personal)' },
{ name: 'Alan Turing', src: photo },
]}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| people * | { name, src?, accentColor? }[] | — | name is also the React key, so it must be unique. |
| max | number | 3 | The rest become +N. |
AspectRatio
A box that keeps its shape, using the CSS aspect-ratio property with a
gradient placeholder behind whatever you put in it.
Preview — 16/9 and 1/1
<AspectRatio ratio="16/9">
<img src={cover} alt="" />
</AspectRatio>
| Prop | Type | Default | Notes |
|---|---|---|---|
| ratio | string | '16/9' | Sets --ar. Any valid aspect-ratio value — 1/1, 9/16. |
| …rest | HTMLAttributes<HTMLDivElement> | — | style is merged after the ratio, so it wins. |
Separator
A hairline in three shapes: a horizontal rule, a vertical divider for toolbars, and a
labelled rule with a word in the middle. Passing label wins over
orientation — a labelled separator is always horizontal.
Preview
<Separator />
<Separator label="or" />
<Separator orientation="vertical" />
| Prop | Type | Default | Notes |
|---|---|---|---|
| orientation | 'horizontal' | 'vertical' | 'horizontal' | Horizontal renders an <hr>; vertical renders a <span role="separator">. |
| label | ReactNode | — | Rule with a word in the middle. Overrides orientation. |
| className | string | — | — |
ScrollArea
A fixed-height viewport with a slim scrollbar and a mask that fades the top and bottom 18 pixels, so content dissolves at the edges instead of being cut off.
Preview — scroll inside it
<ScrollArea height={220}>
{apps.map((app) => <Row key={app.id} {...app} />)}
</ScrollArea>
| Prop | Type | Default | Notes |
|---|---|---|---|
| height | number | string | 168 | A number becomes pixels. Merged into style, which you can override. |
| …rest | HTMLAttributes<HTMLDivElement> | — | — |
Progress
A recessed track with a glowing fill. Leave value undefined for the
indeterminate sweep — which also drops aria-valuenow, the correct signal
for "we don't know how far along this is". Determinate values are clamped to 0–100%.
Preview — 68%, and indeterminate
<Progress value={68} aria-label="Sync" />
<Progress aria-label="Working" /> // indeterminate
<Progress value={34} max={50} accentColor="var(--sleep)" />
| Prop | Type | Default | Notes |
|---|---|---|---|
| value | number | undefined | Undefined means indeterminate. |
| max | number | 100 | The percentage is value / max. |
| accentColor | string | var(--personal) | Sets --accent. |
| aria-label | string | — | Optional but strongly advised. |
Data
The two components with real logic in them.
DataTable generic <Row>
Sorting, searching, pagination and selection over a plain array — no data layer, no context, no controlled state. Selection is keyed by id, so it survives paging and filtering: tick a row, search it away, and it is still selected when it comes back.
The header checkbox is tri-state. It reflects only the current page: checked when every visible row is picked, indeterminate when some are, and ticking it adds the page to the selection rather than replacing it.
Preview — search, sort by any column, select, page
type Mode = { id: string; name: string; owner: string; uses: number };
<DataTable<Mode>
aria-label="Modes"
rows={modes}
rowId={(row) => row.id}
searchFields={(row) => `${row.name} ${row.owner}`}
selectable
pageSize={5}
onSelectionChange={setSelected}
columns={[
{ key: 'name', header: 'Mode' },
{ key: 'owner', header: 'Owner' },
{
key: 'uses',
header: 'Uses',
sortValue: (row) => row.uses, // number, so it sorts numerically
cell: (row) => `${row.uses}×`,
},
{ key: 'actions', header: '', sortable: false, cell: RowMenu },
]}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| rows * | Row[] | — | Never mutated — sorting copies first. |
| columns * | Column<Row>[] | — | See below. |
| rowId * | (row: Row) => string | — | Stable identity, used for the React key and for selection. |
| searchFields | (row: Row) => string | — | The haystack for the search box. Omit it and search does nothing. |
| pageSize | number | 5 | Rows per page. |
| selectable | boolean | false | Adds the checkbox column and the selection bar. |
| onSelectionChange | (ids: string[]) => void | — | The full id list after every change. |
| emptyMessage | (query: string) => ReactNode | — | Shown when nothing matches; receives the current query. |
| aria-label * | string | — | Names the table. |
| Column<Row> | Type | Default | Notes |
|---|---|---|---|
| key * | string | — | Also the fallback accessor: String(row[key]). |
| header * | ReactNode | — | — |
| cell | (row: Row) => ReactNode | — | Omit to render String(row[key]). |
| sortValue | (row: Row) => string | number | — | Return a number for numeric or date columns — otherwise "10" sorts before "9". |
| sortable | boolean | true | Only false disables it; the header renders as plain text. |
| className | string | — | Applied to both the th and every td. |
onSelectionChange
is a notification, not a controlled value — there is no selection prop to
pass back in.
DatePicker
A month grid of 42 cells — always six weeks, so the panel never changes height as you
page through months. Month names and the trigger label come from
toLocaleDateString, so they follow the locale you pass rather
than a bundled translation table.
Preview — ↑ ↓ ← → by day and week, PageUp/PageDown by month
<DatePicker
aria-label="Bedtime date"
locale="en-GB"
weekStartsOn={1}
value={date}
onValueChange={setDate}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| value | Date | — | Pass to control it. |
| defaultValue | Date | today | Today at midnight local time. |
| onValueChange | (date: Date) => void | — | A new Date, not a mutation of the old one. |
| locale | string | 'en-GB' | BCP 47 tag for the month names, weekday initials and trigger label. |
| weekStartsOn | 0 | 1 | 1 | 1 = Monday, 0 = Sunday. |
| aria-label * | string | — | Names the trigger and the calendar dialog. |
| Key | Does |
|---|---|
| ArrowDown (closed) | Opens the calendar |
| ArrowLeft / ArrowRight | Moves the cursor a day, paging the month if it crosses a boundary |
| ArrowUp / ArrowDown | Moves a week |
| PageUp / PageDown | Moves a month, clamping the day to the last valid one |
| Enter | Picks the focused day |
| Escape | Closes and returns focus to the trigger |
Signature
The reason the library exists.
ModeToggle forwardRef → button
The signature control: a slab of glass that lights up from the inside. Turning it on tints the gradient with the accent, blooms a blurred halo underneath, and squishes the slab once — a 0.58s keyframe re-triggered on every press by dropping the class and re-adding it on the next frame.
Preview — flat rack, so you can see the material head-on
<ModeToggle
name="Sleep"
accent="sleep"
icon={<MoonIcon />}
onLabel="Dims at 10:30"
offLabel="Off"
pressed={sleeping}
onPressedChange={setSleeping}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
| name * | ReactNode | — | The large label. |
| onLabel | ReactNode | 'On' | Caption under the name while pressed. |
| offLabel | ReactNode | 'Off' | Caption while not pressed. |
| icon | ReactNode | — | Goes in the dark 44px chip, which stays dark even when the slab is lit. |
| accent | Accent | 'sleep' | The colour it lights up in. |
| pressed | boolean | — | Pass to control it. |
| defaultPressed | boolean | false | — |
| onPressedChange | (pressed: boolean) => void | — | — |
ModeRack
The tilted tray the mode toggles sit on: a 3D plate rotated back 54° with each child
lifted on its own translateZ, so the slabs stack in real depth. Pass
flat to lay it down — which is what the preview above uses.
Preview — the tilted default
<ModeRack>
<ModeToggle name="Sleep" accent="sleep" />
<ModeToggle name="Do not disturb" accent="dnd" />
<ModeToggle name="Personal" accent="personal" />
</ModeRack>
| Prop | Type | Default | Notes |
|---|---|---|---|
| flat | boolean | false | Lays the rack flat instead of tilting it back in 3D. Children stack vertically at full width. |
| children | ReactNode | — | The first three get decreasing lift; a fourth sits flat. |
| …rest | HTMLAttributes<HTMLDivElement> | — | — |
rotateX(54deg) rotateZ(-36deg) on a real 3D plate, so anything
you nest inside inherits the perspective. Put popovers and menus outside the rack.
Utilities
Exported because they're useful outside the library too.
cx function
Join class names, dropping anything falsy. Thirty lines shorter than a dependency and does the same job for the conditional-class case.
cx('card', isOpen && 'open', className)
// 'card open my-class' → falsy parts are dropped
| Signature | Notes |
|---|---|
| cx(...parts: Array<string | false | null | undefined>): string | No object or array syntax — deliberately. If you need that, reach for clsx. |
useControllableState hook
State a consumer may or may not want to own — the hook behind every stateful component
here. Pass value to control it, leave it out to let the component keep its
own. The setter always calls onChange, whether controlled or not.
const [on, setOn] = useControllableState({
value: checked, // undefined ⇒ uncontrolled
defaultValue: false,
onChange: onCheckedChange,
});
| Option | Type | Notes |
|---|---|---|
| value | T | undefined | undefined is the switch: anything else means controlled. |
| defaultValue * | T | Required — the uncontrolled starting value. |
| onChange | (next: T) => void | Called on every set, in both modes. |
| returns | [T, (next: T) => void] | Value and setter, like useState. |
undefined silently hands control back to stale internal
state.
Accent type
'sleep' | 'dnd' | 'personal' — the three named accents. Passing
accent sets --accent to var(--sleep) and so on,
which is why overriding the token in CSS re-colours every component that uses it.
Preview — the same toggle in all three
import type { Accent } from 'glass-ui-react';
// accentStyle(accent) → { '--accent': 'var(--sleep)' }
<Switch label="Silence calls" accent="dnd" />
// or set the property yourself, for a colour outside the three
<div style={{ '--accent': '#7C5CFF' }}>…</div>