@xkdkw/components
v1.1.1
Published
Accessible components for @xkdkw/framework, modelled on React Aria Components: a headless layer (@xkdkw/components/aria) and the Glass styled layer with theme and tokens.
Readme
@xkdkw/components
Accessible UI components for @xkdkw/framework, modelled on React Aria Components and styled in the xkdkw Glass design language.
- Headless layer (
@xkdkw/components/aria): behaviour, ARIA, keyboard and focus handling, state anddata-*attributes, with RAC names and props. It ships no colours. - Glass layer (
@xkdkw/components): the same components styled with frosted glass, tinted fills and neutral selection, plus design primitives, the theme and the tokens.
Coming from 0.6? See MIGRATION.md. Upgrading from 1.0? 1.1 is additive; MIGRATION.md lists the new parts and the patterns they replace.
Install
npm install @xkdkw/components @xkdkw/framework @xkdkw/css-in-jsPeer dependencies: @xkdkw/framework ^1.8.0 and @xkdkw/css-in-js ^0.1.0. The package is ESM only and meant for a bundler (Vite, esbuild); styles are injected at runtime by @xkdkw/css-in-js, so there is no CSS file to import.
JSX compiles to the framework's factory. In tsconfig.json use "jsx": "react", "jsxFactory": "h" and "jsxFragmentFactory": "Fragment", and in Vite set esbuild: { jsxFactory: 'h', jsxFragment: 'Fragment' }. Then import h (and Fragment) from @xkdkw/framework in every .tsx file.
Entry points
| Import | Use it for |
|---|---|
| @xkdkw/components | Everything an app needs: the Glass components, design primitives (Panel, PageHeader, Badge, Skeleton, EmptyState, StatusState, InlineAlert, ConfirmDialog, LinkButton), the theme, the tokens, and every headless export that has no Glass version (hooks, contexts, Collection, RouterProvider, FocusScope, types). Names the Glass layer restyles (Button, Menu, ...) resolve to the Glass version. |
| @xkdkw/components/aria | Building your own look: the unstyled components and all foundation hooks (usePress, useFocusRing, useListState, useOverlayPosition, ...). |
import { h } from '@xkdkw/framework';
import { Button, IconButton, Menu, MenuItem, MenuTrigger, Popover, ThemeProvider, darkTheme } from '@xkdkw/components';
import { Button as BareButton, useFocusRing } from '@xkdkw/components/aria';Set up routing once, so href on Link, LinkButton, MenuItem, Tab, Row, ListBoxItem and SegmentedControlItem navigates client-side:
import { navigate } from '@xkdkw/framework';
import { RouterProvider } from '@xkdkw/components';
<RouterProvider navigate={(href, options) => navigate(href, options)}>
<App />
</RouterProvider>Theming
The Glass components read CSS custom properties (--color-accent, --color-surface-rgb, --color-text-rgb, --glass-alpha, --scrim, ...). THEME_CSS_VARS lists all 25. Two themes are included: lightTheme and darkTheme.
import { applyTheme, darkTheme, lightTheme, ThemeProvider, useThemeScope } from '@xkdkw/components';
applyTheme(document.documentElement, isDark ? darkTheme : lightTheme);
<ThemeProvider theme={darkTheme}>
<Settings />
</ThemeProvider>
function MapOverlay() {
const dark = useThemeScope('dark');
return <div className={dark}>...</div>;
}applyTheme(element, theme)sets every variable andcolor-schemeon an element. Use it on<html>for the page theme.ThemeProviderrenders adisplay: contentswrapper with the variables, anduseTheme()reads the nearest theme.useThemeScope('light' | 'dark')returns a class that forces a scheme on a subtree.- Overlays (popovers, menus, tooltips, modals) portal to the body but copy the trigger's variables, so a scoped subtree themes its overlays too.
- Custom themes spread a base theme:
{ ...darkTheme, colorAccent: '#7c3aed', colorAccentRgb: '124, 58, 237' }. - Motion is reduced under
prefers-reduced-motion: reduceand underhtml[data-motion="reduce"].
Tokens for app styles
The tokens are typed constants for your own makeStyles calls. They are exported individually and as one tokens object:
import { makeStyles } from '@xkdkw/css-in-js';
import { color, fill, glass, ink, radius, space, state, type } from '@xkdkw/components';
const useStyles = makeStyles({
card: { ...glass.panel, paddingTop: space[16], paddingBottom: space[16], borderTopLeftRadius: radius.lg },
meta: { ...type.caption, color: ink.tertiary },
chip: { backgroundColor: fill.base, [state.hovered]: { backgroundColor: fill.hover } },
link: { color: color.accent },
});The token groups are:
- Colour:
color,textA(),surfaceA(),accentA(). - Text:
ink(primary 1, secondary 0.72, tertiary 0.64). Tertiary is the lowest level for text.ink.decorative(0.5) is for rules, dots and icons next to a label, never for text on its own;ink.quaternaryis the same value under its 1.0 name. - Surfaces:
fill,line,glass,shadow. - Scales:
radius(hairline 2, xs 4 to panel 20, pill),space,control(24/28/32/36/44),icon. - Type:
type,numeric.type.largeTitleis the page title (34px,clamp(22px, 8vw, 34px)so it shrinks only on screens narrower than 425px);type.caption2is 11px for dense captions. - Motion:
motion,transition(). - Focus:
focusRing,insetFocusRing. - Layout:
layout,shell,breakpoint,mediaQuery,mq,z. - States:
state, which gives the state selectors in their precedence order.
Page frame and breakpoints
import { makeStyles } from '@xkdkw/css-in-js';
import { control, layout, mediaQuery, mq, shell } from '@xkdkw/components';
const useStyles = makeStyles({
frame: { maxWidth: layout.wide, marginLeft: 'auto', marginRight: 'auto', paddingLeft: layout.gutter, paddingRight: layout.gutter },
column: { maxWidth: layout.narrow, marginLeft: 'auto', marginRight: 'auto' },
nav: { position: 'sticky', top: shell.stickyTop },
anchor: { scrollMarginTop: shell.sectionOffset },
toolbar: { display: 'none', [mq.desktop]: { display: 'flex' } },
chip: { [mq.coarse]: { minHeight: control.md } },
});
const isDesktop = window.matchMedia(mediaQuery.desktop).matches;layout:narrow760 (centred column for text pages),default1024,wide1200 (the header, page and footer frame),gutter16 (side padding on phones).shell:barHeight44 (the site header),stickyTop52 (sticky bars under the header),sectionOffset108 (scroll margin for anchors under the header and a sticky pill bar). The last two follow frombarHeight:stickyTopisbarHeight + space[8]andsectionOffsetisstickyTop + control.lg + space[20]. They are written as literal numbers so their types stay44,52and108.breakpoint:narrow640,medium960,desktop1024. There is one desktop breakpoint:mq.desktop(min-width 1024px) and its counterpartmq.compact(below 1024px) never overlap.mqaddsdesktop,compactandcoarse(pointer: coarse, touch screens).mediaQueryholds the same queries without@media, formatchMedia.
@xkdkw/css-in-js is atomic, so use longhand properties (paddingTop, not padding) and state data attributes, not :hover.
Styling headless components
Every headless component renders a plain default class (Button, ListBoxItem, ModalOverlay) and exposes its state twice. It is available as render props for className, style and children, and as data-* attributes on the DOM element.
import { Button } from '@xkdkw/components/aria';
<Button className={({ isPressed, defaultClassName }) => `${defaultClassName} ${isPressed ? 'down' : ''}`}>
{({ isPending }) => (isPending ? 'Saving' : 'Save')}
</Button>const useStyles = makeStyles({
button: {
backgroundColor: fill.base,
'&[data-hovered]': { backgroundColor: fill.hover },
'&[data-pressed]': { backgroundColor: fill.pressed },
'&[data-focus-visible]': { outlineWidth: 2, outlineStyle: 'solid', outlineColor: color.accent },
'&[data-disabled]': { opacity: 0.5 },
},
});The state attributes are:
- Interaction:
data-hovered,data-pressed,data-focused,data-focus-visible,data-focus-within,data-disabled,data-pending. - Selection:
data-selected,data-selection-mode,data-indeterminate,data-current. - Overlays:
data-open,data-expanded,data-placement,data-trigger,data-entering,data-exiting. - Fields:
data-invalid,data-required,data-readonly,data-empty. - Layout and tables:
data-orientation,data-sort-direction,data-allows-sorting,data-dragging.
A string className replaces the default class; the function form receives defaultClassName to extend it.
On Glass components, a string className is appended to the Glass classes, and a function receives the Glass classes as defaultClassName. Parents configure children through contexts and slots (ButtonContext, TextContext, slot="description"), as in RAC.
Components
Buttons
<Button variant="primary" icon={<PlusIcon />} onPress={create}>New strat</Button>
<Button isPending={saving} pendingLabel="Saving">Save</Button>
<LinkButton href="/tankopedia">Tankopedia</LinkButton>
<IconButton aria-label="More actions" variant="ghost"><DotsIcon /></IconButton>
<ToggleButton isSelected={pinned} onChange={setPinned}>Pinned</ToggleButton>
<SegmentedControl aria-label="Period" selectedKey={days} onSelectionChange={setDays}>
<SegmentedControlItem id="7">7 days</SegmentedControlItem>
<SegmentedControlItem id="30">30 days</SegmentedControlItem>
</SegmentedControl>
<Link href="/strats">All strats</Link>
<Toolbar aria-label="Map tools">...</Toolbar>Button:variantprimary | secondary | ghost | danger | glass,sizesm | md | lg(28/32/36).IconButton: sizesxstolg(24 to 36),shapecircle | rounded, and an automatic tooltip showing itsaria-label. The tooltip opens on mouse hover after 600 ms or on keyboard focus, never on touch;tooltip={false}turns it off.
Selection
<Checkbox isSelected={agree} onChange={setAgree}>I agree</Checkbox>
<CheckboxGroup value={modes} onChange={setModes}>
<Label>Battle modes</Label>
<Checkbox value="random">Random</Checkbox>
<Checkbox value="ranked">Ranked</Checkbox>
</CheckboxGroup>
<RadioGroup value={nation} onChange={setNation}>
<Label>Nation</Label>
<Radio value="ussr">USSR</Radio>
<Radio value="germany">Germany</Radio>
</RadioGroup>
<Switch isSelected={compact} onChange={setCompact}>Compact rows</Switch>Fields
<Form onSubmit={save} validationErrors={serverErrors}>
<TextField name="title" label="Title" isRequired description="Shown on the strat card" />
<SearchField aria-label="Search tanks" value={query} onChange={setQuery} />
<NumberField label="Tier" minValue={1} maxValue={10} value={tier} onChange={setTier} />
<Slider defaultValue={80}>
<Label>Opacity</Label>
<SliderOutput />
<SliderTrack />
</Slider>
<Button type="submit" variant="primary">Save</Button>
</Form>The Glass fields render their own label, description and FieldError, and take size (sm | md | lg) and variant (filled | bare). Pass children to compose the headless anatomy yourself (Label, Input, Text slot="description", FieldError).
Collections
<MenuTrigger>
<IconButton aria-label="Strat options"><DotsIcon /></IconButton>
<Popover placement="bottom end">
<Menu onAction={(key) => run(key)}>
<MenuItem id="rename" icon={<PenIcon />}>Rename</MenuItem>
<MenuItem id="copy" textValue="Duplicate">Duplicate<Keyboard>Ctrl+D</Keyboard></MenuItem>
<Separator />
<MenuItem id="delete" tone="danger">Delete</MenuItem>
</Menu>
</Popover>
</MenuTrigger>
<Select label="Map" selectedKey={map} onSelectionChange={setMap}>
<Section>
<Header>Summer</Header>
<ListBoxItem id="mines">Mines</ListBoxItem>
<ListBoxItem id="canal">Canal</ListBoxItem>
</Section>
</Select>
<ComboBox label="Tank" items={tanks} onSelectionChange={setTank}>
{(tank) => <ListBoxItem id={tank.id}>{tank.name}</ListBoxItem>}
</ComboBox>
<Table aria-label="Tanks" selectionMode="multiple" sortDescriptor={sort} onSortChange={setSort}>
<TableHeader>
<Column id="name" isRowHeader allowsSorting>Tank</Column>
<Column id="battles" allowsSorting>Battles</Column>
</TableHeader>
<TableBody items={rows}>{(row) => <Row id={row.id}><Cell>{row.name}</Cell><Cell>{row.battles}</Cell></Row>}</TableBody>
</Table>Collection children must be the library's items, sections, headers and separators (arrays, fragments and Collection elements of them are fine); put custom content inside an item. ListBox, Menu, Select and ComboBox support single and multiple selection, sections and typeahead. Menus support SubmenuTrigger and checkable sections.
Overlays
<DialogTrigger>
<Button>Share</Button>
<Modal size="sm" isDismissable>
<Dialog>
{({ close }) => [<Heading key="t" slot="title">Share strat</Heading>, <Button key="b" onPress={close}>Done</Button>]}
</Dialog>
</Modal>
</DialogTrigger>
<DialogTrigger>
<Button>Filters</Button>
<Popover showArrow>
<Dialog aria-label="Filters">...</Dialog>
</Popover>
</DialogTrigger>
<TooltipTrigger>
<Button>Export</Button>
<Tooltip>Download as PNG</Tooltip>
</TooltipTrigger>const toasts = new ToastQueue<ToastMessage>({ maxVisibleToasts: 4 });
<ToastRegion queue={toasts} />
toasts.add({ title: 'Link copied', tone: 'success' }, { timeout: 4000 });A Modal becomes a bottom sheet below 640 px. Popovers have no padding; put a Dialog, Menu or ListBox inside. F6 moves focus into and out of the toast region.
Modal size is xs 420, sm 440, md 640 (default), lg 880 or xl 1100. fullscreenBelow="narrow" | "medium" | "desktop" makes the sheet cover the whole screen below 640, 960 or 1024 px instead of becoming a bottom sheet. For a width off the scale, style={{ '--sheet-width': '560px' }} still works.
<Modal size="lg" fullscreenBelow="narrow" isDismissable>
<Dialog>...</Dialog>
</Modal>Feedback and states
<InlineAlert tone="success" title="Background approved">It now shows on your profile.</InlineAlert>
<InlineAlert tone="danger" action={<Button size="sm" variant="ghost">Retry</Button>} onDismiss={hide}>Upload failed.</InlineAlert>
<EmptyState icon={<SearchIcon />} title="No tanks match" description="Remove a filter to see more." action={<Button size="sm" variant="ghost">Clear filters</Button>} />
<EmptyState size="page" headingLevel={1} title="Page not found" action={<LinkButton href="/">Go home</LinkButton>} />
{status === 'loading' ? <StatusState.Loading rows={6} rowHeight={44} /> : null}
{status === 'error' ? <StatusState.Error description={message} onRetry={reload} /> : null}
<ConfirmDialog isOpen={open} onOpenChange={setOpen} title="Delete map?" confirmLabel="Delete" tone="danger" onConfirm={() => api.deleteMap(id)}>
The map and its drawings are removed for everyone.
</ConfirmDialog>
const { confirm, dialog } = useConfirm();
if (await confirm({ title: 'Ban player?', confirmLabel: 'Ban', tone: 'danger' })) ban(id);InlineAlert:toneinfo | success | warning | danger(defaultinfo), optionaltitle,icon(nullhides the tone glyph),action,onDismiss(adds a ghost close button labelleddismissLabel). The role comes from the tone:statusfor info and success,alertfor warning and danger;roleoverrides it (nullremoves it). Use it for conditions that stay; transient results go in a toast.EmptyState:title,description,icon,action,toneneutral | danger,sizeinline | panel | page(min height 0/180/320),headingLevelto make the title a heading. Neutral states arerole="status", danger statesrole="alert";roleoverrides it.StatusState.Loading(StatusLoading):rows(3) Skeleton rows ofrowHeight(36) in astatusregion labelled "Loading". MatchrowsandrowHeightto the content it stands in for, so nothing moves when it arrives.StatusState.Error(StatusError): a dangerEmptyStatewith the default title "Couldn’t load this" and, withonRetry, a ghost sm Retry button (retryLabel,isRetrying).StatusState.EmptyisEmptyState.ConfirmDialog:isOpen/defaultOpen/onOpenChange,title, children as the description,confirmLabel,cancelLabel,toneprimary | danger,size(defaultsm),fullscreenBelow,isConfirmDisabled,onConfirm,onCancel. It is arole="alertdialog"sheet with Cancel and the confirm action in aDialogActionsrow. WhenonConfirmreturns a promise, the confirm button shows its spinner, Cancel, Escape and the scrim are disabled, and the dialog closes when the promise resolves (it stays open if it rejects, so report the error fromonConfirm). PassisPendingto drive the pending state yourself; the dialog then leaves closing to you.onCancelruns for every close except the confirm action: Cancel, Escape and the scrim.onOpenChangeruns for every close, including the confirm action. Also works inside aDialogTrigger, whereonCancelcovers the same three.useConfirm()returns{ confirm, dialog }: renderdialogonce, thenawait confirm(options)resolvestrueafter the confirm action (and after an asyncoptions.onConfirm) orfalseon Cancel, Escape or a newerconfirm()call.DialogActions: the right-aligned button row used byConfirmDialog, for your own dialogs.
Structure and status
<Tabs selectedKey={tab} onSelectionChange={setTab}>
<TabList aria-label="Player" variant="pill">
<Tab id="overview">Overview</Tab>
<Tab id="tanks">Tanks</Tab>
</TabList>
<TabPanel id="overview">...</TabPanel>
<TabPanel id="tanks">...</TabPanel>
</Tabs>
<DisclosureGroup>
<Disclosure id="armor">
<DisclosureHeader>Armor</DisclosureHeader>
<DisclosurePanel>...</DisclosurePanel>
</Disclosure>
</DisclosureGroup>
<ProgressBar value={progress}>
<Label>Uploading</Label>
</ProgressBar>
<Meter value={72} variant="warning">
<Label>Storage</Label>
</Meter>Page headers
<PageHeader
title="Patch 11.8"
subtitle="Released 30 September 2026, compared with 11.7."
facts={[{ id: 'buffs', value: 24, label: 'buffs' }, { id: 'nerfs', value: 11, label: 'nerfs' }]}
actions={<Button size="sm">Share</Button>}
footer={<VersionPicker />}
/>PageHeader renders the page title (h1 by default, level changes it) in type.largeTitle, an optional subtitle capped at 640px, facts as plain inline text ("24 buffs 11 nerfs"; pass { value, label } objects or your own content), actions at the end of the title row and a footer below a hairline (filters, a version picker). There is no eyebrow and no hero icon. It renders a div; elementType="header" changes that.
Display and typography
<Panel title="Recent" icon={<ClockIcon />} actions={<IconButton aria-label="Refresh"><RefreshIcon /></IconButton>}>
<Text variant="footnote" tone="secondary">Last 30 days</Text>
</Panel>
<Badge tone="premium">Premium</Badge>
<Skeleton width={120} />
<Heading level={2}>Tank list</Heading>
<Keyboard>Ctrl+K</Keyboard>
<VisuallyHidden>Opens in a new tab</VisuallyHidden>Labels
The library renders a few strings itself: close buttons, pending spinners, the search clear button, stepper buttons, the combo box button, the select placeholder, table selection checkboxes and announcements, hidden dismiss buttons, validation fallbacks and the new state components. They default to English. Wrap the app once in LabelsProvider to translate them:
import { LabelsProvider } from '@xkdkw/components';
<LabelsProvider labels={{ close: t('common.close'), loading: t('common.loading'), cancel: t('common.cancel'), clearSearch: t('common.clearSearch'), retry: t('common.retry'), loadFailed: t('common.loadFailed') }}>
<App />
</LabelsProvider>| Key | Default | Used by |
|---|---|---|
| close | Close | Dialog sheet close button, Toast close button |
| loading | Loading | Button and IconButton isPending spinner, StatusState.Loading |
| clearSearch | Clear search | SearchField clear button |
| cancel | Cancel | ConfirmDialog |
| retry | Retry | StatusState.Error |
| loadFailed | Couldn’t load this | StatusState.Error title |
| dismiss | Dismiss | InlineAlert close button, hidden overlay dismiss buttons |
| increase, decrease | Increase, Decrease | NumberField steppers |
| empty | Empty | NumberField announcement when cleared |
| showSuggestions | Show suggestions | ComboBox button |
| selectPlaceholder | Select an item | Select placeholder |
| select, selectAll | Select, Select All | Table selection checkboxes |
| allSelected, noneSelected | All items selected., No items selected. | Table announcements when everything or nothing is selected |
| longPressSelection | Long press to enter selection mode. | Table description on touch screens when rows have actions |
| invalidValue | Invalid value. | native validation fallback message |
| longPressMenu | Long press or press Alt + ArrowDown to open menu | long-press MenuTrigger description |
| more | More | not used by a component yet; read it for ⋮ buttons with useLabels().more |
Providers nest and merge: an inner provider overrides only the keys it sets, and empty strings are ignored. Props such as closeLabel, pendingLabel and clearButtonLabel still win over the provider. useLabels() reads the current set, and DEFAULT_LABELS is the English set.
Strings that build in a count or a row name are still English, because a single label cannot carry the plural or the word order: the ToastRegion name ("1 notification", "3 notifications") and the Table announcements "3 items selected.", "IS-7 selected." and "IS-7 not selected.". Give ToastRegion an aria-label to replace its name.
Headless foundations
@xkdkw/components/aria also exports the building blocks the components use, with RAC names:
- Interactions:
usePress,useHover,useFocus,useFocusWithin,useFocusRing,useKeyboard,useMove,useLongPress,useInteractOutside, and the focus-visible modality helpers. - Focus:
FocusScope,useFocusManager,focusSafely,getFocusableTreeWalker. - Overlays:
Overlay,usePortalHost,useOverlayTriggerState,useOverlayTrigger,useOverlay,useOverlayPosition,calculatePosition,usePreventScroll,ariaHideOutside,DismissButton. - Collections and selection:
useListState,useSingleSelectListState,SelectionManager,ListKeyboardDelegate,useSelectableCollection,useSelectableItem,useTypeSelect,markCollectionNode,Collection. - Utilities:
mergeProps,mergeRefs,useObjectRef,useControlledState,useRenderProps,useContextProps,Provider,RouterProvider,LabelsProvider,useLabels,filterDOMProps,useId,announce.
Not included in 1.0: virtualizer, drag and drop, date and time pickers, color picker and tree. RAC's render prop (DOM element replacement) is not supported.
Development
npm run typecheck # source and tests
npm test # happy-dom suite (npm test -- <filter> runs matching files)
npm run build # emits dist/ with both entry pointsThe playground in playground/ is a Vite gallery with one page per component (config: playground/vite.config.mjs, which resolves both entry points to src/).
License: MIT.
