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

Terminal
pnpm add ajo@0.1.35 ajo-ui@0.1.2

ajo-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:

TSX
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

GroupFamily subpaths
Foundationsdirection, field, input-group
Disclosure and layoutaccordion, collapsible, dialog, drawer, resizable, sidebar
Navigation and menuscommand, context-menu, menu, menubar, navigation-menu, tabs, toolbar
Inputs and selectioncalendar, checkbox, checkbox-group, input-date, input-otp, radio-group, select, slider, switch, toggle, toggle-group
Overlays and feedbackpopover, progress, toast, tooltip
Data and displayavatar, 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

src/notes/editor.tsx
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}.

  • activationMode is automatic by default, selecting a tab as focus reaches it; manual waits for Enter, Space or a click.
  • orientation is horizontal or vertical, and dir sets the arrow direction.
  • Inactive panels are not rendered unless forceMount keeps them mounted and hidden.
  • The list carries --indicator-* variables for a sliding marker and data-overflow-x or data-overflow-y while its triggers overflow.

Dialog

src/notes/delete-note.tsx
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.

src/styles/editor.css
[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:

ValuesUsed by
open, closedDisclosure and popup families
active, inactiveTabs
checked, indeterminate, uncheckedCheckbox
checked, uncheckedBinary native choice controls
on, offToggles
complete, incompleteInput OTP
loading, complete, indeterminateProgress
selected, unselectedCalendar day buttons
expanded, collapsedSidebar on desktop

Controlled and uncontrolled state

Families that hold state use matching argument groups:

ControlledUncontrolledChange callback
valuedefaultValueonValueChange(value, event)
opendefaultOpenonOpenChange(open, event)
checkeddefaultCheckedonCheckedChange(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.

TSX
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.

TSX
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:

AreaExports
Popup compositionPopupPlacement, PopupPosition, triggerAttrs, popupStyle
Component adaptersOmitArg, FixedArgs, withSlot
Checked stateCheckedState, ariaChecked, syncCheckedState
Values and filteringbool, flag, text, strings, matchesTokens, defaultResultsLabel, resolveFilter, toNumber, emptyChildren
Classes and stylesclx, 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.

Source of truth: ajo-ui README