Packages
Unstyled UI
ajo-ui: accessible component families with stable styling hooks.
ajo-ui is a set of accessible component families for Ajo with no visual design of its own. Each family renders semantic elements, wires the ARIA relationships between its parts, handles the keyboard, and manages controlled or uncontrolled state. What it leaves to you is every pixel: you style its stable data-slot and state attributes with your own CSS, or use a theme such as Playa that is built on top of it.
The families prefer native semantics before emulating them: a <dialog> element for modal conversations, the Popover API for non-modal floating surfaces, <details> for disclosure, native inputs for form ownership, and real buttons and links for activation and navigation. Their shared behaviors come from ajo-cloves.
Install
pnpm add ajo@0.1.35 ajo-ui@0.1.2ajo-ui requires ajo ^0.1.35. It ships compiled ES modules and .d.ts declarations for the root and every family. Positioned families use @floating-ui/dom internally, pinned as a regular dependency; applications do not import it.
Imports
Import a family from its own subpath, or everything from the package root:
import { Popover, PopoverContent, PopoverTrigger } from 'ajo-ui/popover'
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'ajo-ui'Both forms are side-effect-free and tree-shakeable: importing one family through the root keeps the same modules as its subpath. Helpers for building your own components live at ajo-ui/utils.
Families
| Group | Family subpaths |
|---|---|
| Foundations | direction, field, input-group |
| Disclosure and layout | accordion, collapsible, dialog, drawer, resizable, sidebar |
| Navigation and menus | command, context-menu, menu, menubar, navigation-menu, tabs, toolbar |
| Inputs and selection | calendar, checkbox, checkbox-group, input-date, input-otp, radio-group, select, slider, switch, toggle, toggle-group |
| Overlays and feedback | popover, progress, toast, tooltip |
| Data and display | avatar, carousel, chart, data-table, message-scroller, virtual-list |
A family exports its root and parts under one name, such as Tabs, TabsList, TabsTrigger and TabsContent, together with their argument types. Positioned families take two positioning arguments: placement (top, right, bottom or left, optionally with -start or -end, or auto) and gap in CSS pixels.
Tabs
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'ajo-ui/tabs'
export default () => (
<Tabs defaultValue="write" class="editor-tabs">
<TabsList aria-label="Note">
<TabsTrigger value="write">Write</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
</TabsList>
<TabsContent value="write">
<textarea name="text" aria-label="Note text" />
</TabsContent>
<TabsContent value="preview">Nothing to preview yet.</TabsContent>
</Tabs>
)The list gets role="tablist", each trigger is a button with role="tab", aria-selected and aria-controls, and each panel is a tabpanel labelled by its trigger. Only the selected trigger is in the tab order; arrow keys, Home and End move between triggers and wrap at the ends unless loop={false}.
activationModeisautomaticby default, selecting a tab as focus reaches it;manualwaits for Enter, Space or a click.orientationishorizontalorvertical, anddirsets the arrow direction.- Inactive panels are not rendered unless
forceMountkeeps them mounted and hidden. - The list carries
--indicator-*variables for a sliding marker anddata-overflow-xordata-overflow-ywhile its triggers overflow.
Dialog
import {
Dialog, DialogClose, DialogContent, DialogDescription,
DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from 'ajo-ui/dialog'
export default ({ onDelete }: { onDelete: () => void }) => (
<Dialog>
<DialogTrigger class="button">Delete note</DialogTrigger>
<DialogContent class="dialog">
<DialogHeader>
<DialogTitle>Delete this note?</DialogTitle>
<DialogDescription>It disappears from every open tab. This cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose class="button">Keep it</DialogClose>
<DialogClose class="button danger" set:onclick={onDelete}>Delete</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)DialogContent renders a native <dialog> opened with showModal(), or with show() when modal={false}. It is labelled by DialogTitle and described by DialogDescription. Escape and a click on the backdrop close it; call preventDefault() in onEscapeKeyDown or onPointerDownOutside to keep it open. On close, focus returns to the trigger. A handler you pass to DialogClose or DialogTrigger runs first and can prevent the default action the same way.
Styling hooks
Every part renders a data-slot attribute, the stable vocabulary for selectors, tests and themes: tabs-list, tabs-trigger, dialog-content, command-item and so on. Parts also accept class, and some families take class maps or state callbacks, such as classNames and dayClassName on Calendar.
[data-slot="tabs-list"] { display: flex; gap: 0.25rem; }
[data-slot="tabs-trigger"][data-state="active"] { border-bottom: 2px solid currentColor; }
[data-slot="dialog-content"] { max-width: 28rem; padding: 1.5rem; border-radius: 12px; }
[data-slot="dialog-content"]::backdrop { background: rgb(0 0 0 / 0.5); }
[data-slot="command-item"][data-highlighted="true"] { background: var(--highlight); }Boolean states render as data-x="true" while active and are absent otherwise, as with data-highlighted above. ARIA values that need an explicit negative render "true" or "false". data-state appears only where a family has a named state:
| Values | Used by |
|---|---|
open, closed | Disclosure and popup families |
active, inactive | Tabs |
checked, indeterminate, unchecked | Checkbox |
checked, unchecked | Binary native choice controls |
on, off | Toggles |
complete, incomplete | Input OTP |
loading, complete, indeterminate | Progress |
selected, unselected | Calendar day buttons |
expanded, collapsed | Sidebar on desktop |
Controlled and uncontrolled state
Families that hold state use matching argument groups:
| Controlled | Uncontrolled | Change callback |
|---|---|---|
value | defaultValue | onValueChange(value, event) |
open | defaultOpen | onOpenChange(open, event) |
checked | defaultChecked | onCheckedChange(checked, event) |
Leave the controlled argument undefined and the component keeps its own state from the default. Pass a value and it follows yours, reporting requested changes through the callback. Families whose empty value is null treat null as controlled, and array values use [] as their controlled empty value. Some families add pairs on the same model, such as Toggle’s pressed, defaultPressed and onPressedChange.
const Search: Stateful = function* () {
let open = false
const change = (next: boolean) => this.next(() => open = next)
while (true) yield (
<Dialog open={open} onOpenChange={change}>
…
</Dialog>
)
}Localization and direction
Every visible or assistive-technology string has an English default and an argument to replace it, for example resultsLabel on Command, closeLabel on Drawer, and nextMonthLabel or previousMonthLabel on Calendar.
DirectionProvider from ajo-ui/direction sets the inherited text direction and an HTML dir attribute on its element. Families with horizontal keyboard movement, such as Tabs and Toolbar, also accept a local dir that wins over the provider.
import { DirectionProvider } from 'ajo-ui/direction'
<DirectionProvider dir="rtl">
<App />
</DirectionProvider>Component utilities
ajo-ui/utils holds helpers and types for custom components, themes and adapters:
| Area | Exports |
|---|---|
| Popup composition | PopupPlacement, PopupPosition, triggerAttrs, popupStyle |
| Component adapters | OmitArg, FixedArgs, withSlot |
| Checked state | CheckedState, ariaChecked, syncCheckedState |
| Values and filtering | bool, flag, text, strings, matchesTokens, defaultResultsLabel, resolveFilter, toNumber, emptyChildren |
| Classes and styles | clx, stlx, StyleValue, StyleObject, StyleInput |
OmitArg removes named arguments while keeping Ajo’s open argument index, and FixedArgs marks arguments an adapter supplies itself. withSlot re-exports a part with a fixed data-slot. flag(value) returns "true" or undefined, the boolean state convention above. clx joins conditional class names, and stlx builds an inline style string from strings, objects and arrays.
- PlayaA complete theme built on these families.
- Cloves catalogThe behaviors underneath, for your own components.
- ComponentsStateless and stateful components in Ajo.
Source of truth: ajo-ui README