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.

Every preview on this page is live. The panels below load the library's own stylesheet (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 pointPathNotes
import./dist/index.jsESM build
require./dist/index.cjsCommonJS build
types./dist/index.d.tsBundled declarations
./styles.css./dist/styles/liquid.cssThe 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} />
PatternValue propDefault propChange handler
CheckedcheckeddefaultCheckedonCheckedChange(boolean)
PressedpresseddefaultPressedonPressedChange(boolean)
ValuevaluedefaultValueonValueChange(value)
OpenopendefaultOpenonOpenChange(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);
}
TokenLightDarkWhat it drives
--sleep#3E7BFA#5B95FFBlue accent, the default
--dnd#FF3428#FF4033Red accent, danger
--personal#FFA51F#FFB23DAmber accent, warning
--ok#22A06B#3ED9A0Success tone
--accentvar(--sleep)Per-component accent; what accent sets
--bg-1 / --bg-2#EFEDE9 / #DAD7D1#1A1A1C / #070708Page gradient
--ink / --ink-soft#101012#F2F1EFText, and its muted pair
--hair14–16% inkHairline rules and borders
--pill-top/mid/bottranslucent whitesThe raised-glass gradient
--well5% ink30% blackRecessed surfaces (inputs, tracks)
--panel88% white88% #141418Menus, popovers, calendars
--panel-free58% white60% #141418Dialogs and toasts
--blur / --sat22px / 1.724px / 1.5backdrop-filter strength
--gloss / --castwhites / shadowsInner highlight, outer shadow
--glow-a / --glow-b.55 / .22.85 / .42Halo and bloom intensity when lit
--r999pxThe pill radius
--easecubic-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

backdrop-filter makes an element a backdrop root. Anything nested inside it can no longer blur its own siblings. That is why 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.

ComponentWhat it does
Switch / CheckboxReal <input type="checkbox">, so keyboard and form submission come free. role="switch" on the former, indeterminate on the latter.
RadioGroupReal radios in a role="radiogroup"; arrow keys are the browser's.
Dialog / AlertDialogWraps <dialog> — focus trapping, inertness and Escape are the platform's job. AlertDialog adds role="alertdialog".
TabsRoving tab index; Arrow Left/Right moves selection and focus together.
Select / DropdownMenuArrow keys walk the list, Enter picks, Escape closes and restores focus to the trigger.
DatePickerArrows by day, Up/Down by week, PageUp/PageDown by month, Enter to pick. aria-live on the month title.
DataTablearia-sort on exactly one column; a tri-state header checkbox that goes indeterminate when a page is partly selected.
IconButtonaria-label is a required prop — an icon button has no visible text to name it.
TooltipPure 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

data-theme = dark
<ThemeProvider defaultTheme="dark">
  <App />
</ThemeProvider>
PropTypeDefaultNotes
children *ReactNode—Your app.
defaultTheme'light' | 'dark''light'Initial theme. Not reactive — use setTheme after mount.
targetHTMLElement | nulldocument.documentElementThe 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>
ReturnsTypeNotes
theme'light' | 'dark'The current theme.
setTheme(theme: Theme) => voidSwitches 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>
The orbs are one blue, one red and one amber — the three accents. Their opacity comes from --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>
PropTypeDefaultNotes
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.
classNamestring—Appended after push and the variant class.
…restButtonHTMLAttributes—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>
PropTypeDefaultNotes
aria-label *string—Required by the type. There is no visible label to fall back on.
pressedbooleanundefinedSets aria-pressed. Leave it off for a plain action button — undefined omits the attribute entirely.
typestring'button'Same default as Button.
…restButtonHTMLAttributes—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>
PropTypeDefaultNotes
pressedboolean—Pass to control it.
defaultPressedbooleanfalseUncontrolled starting state.
onPressedChange(pressed: boolean) => void—Fires before your own onClick.
accent'sleep' | 'dnd' | 'personal''sleep'The colour it lights up in.
widebooleanfalseAuto-width pill for a text label instead of a 38px square.
…restButtonHTMLAttributes—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}
/>
PropTypeDefaultNotes
items *ToggleGroupItem[]—{ value, label, 'aria-label'? }
type'single' | 'multiple''multiple'single keeps at most one pressed, and allows none.
valuestring[]—Always an array, even for single.
defaultValuestring[][]Uncontrolled starting selection.
onValueChange(value: string[]) => void—Called with the whole next array.
accentAccent'sleep'Applied to every item.
widebooleanfalseApplied 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}
/>
PropTypeDefaultNotes
label *ReactNode—The row's text. The whole row is clickable.
hintReactNode—Secondary text on the far side of the row.
accentAccent'sleep'Colour of the lit track.
checkedboolean—Pass to control it.
defaultCheckedbooleanfalseUncontrolled starting state.
onCheckedChange(checked: boolean) => void—Fires on the input's change.
disabledbooleanfalseNative disabled.
name / valuestring—For form submission.
classNamestring—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}
/>
PropTypeDefaultNotes
indeterminatebooleanfalseShows 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}
/>
PropTypeDefaultNotes
options *RadioOption[]—{ value, label, hint?, disabled? }
valuestring—Pass to control it.
defaultValuestringfirst option's valueFalls back to '' if the list is empty.
onValueChange(value: string) => void——
namestringgeneratedSet it for form submission.
accentAccent'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}
/>
PropTypeDefaultNotes
options *{ value: string; label: ReactNode }[]—No hint here — segments are one word each.
valuestring—Pass to control it.
defaultValuestringfirst 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}
/>
PropTypeDefaultNotes
options *SelectOption[]—{ value, label, meta? } — meta is right-aligned detail.
valuestring—Pass to control it.
defaultValuestringfirst option's value—
onValueChange(value: string) => void——
accentColorstringvar(--sleep)Any CSS colour; drives the swatch, the ring and the selected dot.
disabledbooleanfalseDisables the trigger.
aria-label *string—Names both the trigger and the listbox.
Open state is internal by design — a select that stays open across a re-render is a bug, not a feature. Reach for 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}
/>
PropTypeDefaultNotes
valuenumber—Pass to control it.
defaultValuenumber50—
onValueChange(value: number) => void—Already coerced to a number.
min / max / stepnumber0 / 100 / 1Passed straight to the input.
accentColorstringvar(--personal)Sets --sl: the fill and its trailing bloom.
disabledbooleanfalse—
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

min
How long before the screen dims
That doesn't look like an address
<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} />
PropTypeDefaultNotes
iconReactNode—Leading glyph, usually a 24×24 svg. Tints to the accent on focus.
suffixReactNode—Text after the field, e.g. a unit.
hintReactNode—Shown under the field; turns red when invalid.
invalidbooleanfalseRed ring and aria-invalid.
onClear() => void—Renders the clear button. You clear the value yourself.
accentAccent'sleep'Focus ring colour.
wrapperClassNamestring—Goes on the .field wrapper; className goes on the input itself.
…restInputHTMLAttributes—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

Shown on the mode's card57/140
<Textarea
  maxLength={140}
  showCount
  hint="Shown on the mode's card"
  value={note}
  onChange={(e) => setNote(e.target.value)}
/>
PropTypeDefaultNotes
showCountbooleanfalseLive count against maxLength. Needs maxLength set.
hint / invalid / accent——Same as Input.
wrapperClassNamestring—Same split as Input.
…restTextareaHTMLAttributes—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

Starts at 22:30 and ends when your first alarm goes off.
<Tabs
  aria-label="Mode detail"
  items={[
    { value: 'schedule', label: 'Schedule', content: <Schedule /> },
    { value: 'people', label: 'People', content: <People /> },
  ]}
  onValueChange={setTab}
/>
PropTypeDefaultNotes
items *TabItem[]—{ value, label, content }
valuestring—Pass to control it.
defaultValuestringfirst item's value—
onValueChange(value: string) => void—Fires on click and on arrow keys.
aria-label *string—Names the tablist.
classNamestring—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> },
  ]}
/>
PropTypeDefaultNotes
items *AccordionItemData[]—{ value, title, content }
type'single' | 'multiple''single'single closes the others.
valuestring[]—The open items. An array even for single.
defaultValuestring[][]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>
PropTypeDefaultNotes
label *ReactNode—Trigger text when closed.
openLabelReactNodelabelReplaces it when open.
openboolean—Pass to control it.
defaultOpenbooleanfalse—
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

<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>
PropTypeDefaultNotes
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.
openboolean—Pass to control it.
defaultOpenbooleanfalse—
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.
classNamestring—On the anchor; panelClassName is on the panel.
Closing returns focus to the trigger button — that is what the ref in the render props is for. Forward it or focus will land on <body>.

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

Copy link The bubble is a sibling, not a portal
<Tooltip label="Copy link">
  <IconButton aria-label="Copy link"><LinkIcon /></IconButton>
</Tooltip>
PropTypeDefaultNotes
label *ReactNode—Bubble contents. It does not wrap — keep it short.
children *ReactElement—Exactly one element. Cloned to receive aria-describedby.
A tooltip is a description, not a name. An 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

Last edited by AL Ada LovelaceDesign systems Owns the glass material and the three accents. Wrote the first note on why cards aren't blurred. 128commits14components three minutes ago.
<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>
PropTypeDefaultNotes
trigger *ReactNode—Rendered inside a button with a dashed underline.
children *ReactNode—Panel contents.
openDelaynumber170Milliseconds before it appears.
closeDelaynumber140Milliseconds before it leaves.
aria-labelstring—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>
PropTypeDefaultNotes
open *boolean—Controlled only. An effect calls showModal() / close().
onClose *() => void—Backdrop click and Escape.
title *ReactNode—Serif heading.
descriptionReactNode—Muted paragraph under it.
iconReactNode—Small glass chip above the title, tinted with the accent.
childrenReactNode—Wrapped in a .fields grid.
footerReactNode—Buttons along the bottom; they stretch to fill the row.
accentColorstringvar(--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"
/>
PropTypeDefaultNotes
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.
descriptionReactNode——
iconReactNode—Chip above the title.
cancelLabelstring'Cancel'—
confirmLabelstring'Confirm'—
destructivebooleantruefalse 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 propTypeDefaultNotes
children *ReactNode—Your tree.
maxnumber3Older toasts drop off the top once this many are on screen.
defaultDurationnumber4500Milliseconds, used when a toast doesn't set its own.
ToastOptionsTypeDefaultNotes
title *string——
descriptionstring——
tone'info' | 'success' | 'warning' | 'danger''info'Maps to --sleep, --ok, --personal, --dnd.
durationnumberdefaultDuration0 keeps it until dismissed.
action{ label, onClick }—Clicking it runs the handler and dismisses the toast.
useToast returnsTypeNotes
toast(options: ToastOptions) => numberReturns the id, so you can dismiss it early.
dismiss(id: number) => voidRemoves it immediately.
Throws if called outside a 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

Bedtime22:30
Alarm06:45

Status

Synced 2 pending
<Card title="Tonight">
  <p>Bedtime 22:30</p>
</Card>
PropTypeDefaultNotes
titleReactNode—Rendered as an <h2> in small caps. The native title attribute is omitted from the type to make room for it.
childrenReactNode——
…restHTMLAttributes<HTMLDivElement>——
Cards are translucent but deliberately not blurred — see A note on glass. Add 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

No dot Online Degraded Offline
<Badge>No dot</Badge>
<Badge color="var(--ok)">Online</Badge>
PropTypeDefaultNotes
colorstring—Sets --bc. Omit it and no dot renders.
…restHTMLAttributes<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

AL GH AT
<Avatar name="Ada Lovelace" />
<Avatar name="Grace Hopper" src={photo} status="online" accentColor="var(--personal)" />
PropTypeDefaultNotes
name *string—The alt text, and the source of the fallback initials.
srcstring—Falls back to initials on error.
status'online' | 'none''none'Green dot at the bottom-right.
accentColorstringvar(--sleep)The gradient behind the initials.
…restHTMLAttributes<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

AL GH AT +2
<AvatarStack
  max={3}
  people={[
    { name: 'Ada Lovelace' },
    { name: 'Grace Hopper', accentColor: 'var(--personal)' },
    { name: 'Alan Turing', src: photo },
  ]}
/>
PropTypeDefaultNotes
people *{ name, src?, accentColor? }[]—name is also the React key, so it must be unique.
maxnumber3The 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

16 / 9
1 / 1
<AspectRatio ratio="16/9">
  <img src={cover} alt="" />
</AspectRatio>
PropTypeDefaultNotes
ratiostring'16/9'Sets --ar. Any valid aspect-ratio value — 1/1, 9/16.
…restHTMLAttributes<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" />
PropTypeDefaultNotes
orientation'horizontal' | 'vertical''horizontal'Horizontal renders an <hr>; vertical renders a <span role="separator">.
labelReactNode—Rule with a word in the middle. Overrides orientation.
classNamestring——

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

MessagesOn
MailOff
CalendarOn
RemindersOn
PhotosOff
MusicOff
PodcastsOff
FitnessOn
<ScrollArea height={220}>
  {apps.map((app) => <Row key={app.id} {...app} />)}
</ScrollArea>
PropTypeDefaultNotes
heightnumber | string168A number becomes pixels. Merged into style, which you can override.
…restHTMLAttributes<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)" />
PropTypeDefaultNotes
valuenumberundefinedUndefined means indeterminate.
maxnumber100The percentage is value / max.
accentColorstringvar(--personal)Sets --accent.
aria-labelstring—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 },
  ]}
/>
PropTypeDefaultNotes
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.
pageSizenumber5Rows per page.
selectablebooleanfalseAdds 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>TypeDefaultNotes
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".
sortablebooleantrueOnly false disables it; the header renders as plain text.
classNamestring—Applied to both the th and every td.
Selection, sort, search and page are all internal state. 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}
/>
PropTypeDefaultNotes
valueDate—Pass to control it.
defaultValueDatetodayToday at midnight local time.
onValueChange(date: Date) => void—A new Date, not a mutation of the old one.
localestring'en-GB'BCP 47 tag for the month names, weekday initials and trigger label.
weekStartsOn0 | 111 = Monday, 0 = Sunday.
aria-label *string—Names the trigger and the calendar dialog.
KeyDoes
ArrowDown (closed)Opens the calendar
ArrowLeft / ArrowRightMoves the cursor a day, paging the month if it crosses a boundary
ArrowUp / ArrowDownMoves a week
PageUp / PageDownMoves a month, clamping the day to the last valid one
EnterPicks the focused day
EscapeCloses 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}
/>
PropTypeDefaultNotes
name *ReactNode—The large label.
onLabelReactNode'On'Caption under the name while pressed.
offLabelReactNode'Off'Caption while not pressed.
iconReactNode—Goes in the dark 44px chip, which stays dark even when the slab is lit.
accentAccent'sleep'The colour it lights up in.
pressedboolean—Pass to control it.
defaultPressedbooleanfalse—
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>
PropTypeDefaultNotes
flatbooleanfalseLays the rack flat instead of tilting it back in 3D. Children stack vertically at full width.
childrenReactNode—The first three get decreasing lift; a fourth sits flat.
…restHTMLAttributes<HTMLDivElement>——
The tilt is 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
SignatureNotes
cx(...parts: Array<string | false | null | undefined>): stringNo 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,
});
OptionTypeNotes
valueT | undefinedundefined is the switch: anything else means controlled.
defaultValue *TRequired — the uncontrolled starting value.
onChange(next: T) => voidCalled on every set, in both modes.
returns[T, (next: T) => void]Value and setter, like useState.
Don't switch a component between controlled and uncontrolled mid-life. Going from a real value to 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>

Set a bedtime

Sleep starts 30 minutes before, easing the screen down so the change is gradual.

Delete this mode?

Its schedule and app list go with it. This can't be undone.