Preview
Installation
Install the runtime packages:
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives
Or scaffold the component source directly into your codebase (shadcn-style):
pnpm dlx @oshon-ai/cli add splitbutton
Wire the tokens into your Tailwind v4 stylesheet:
/* app/globals.css */ @import 'tailwindcss'; @import '@oshon-ai/tokens/css'; @import '@oshon-ai/tokens/tailwind';
New here? Walk through the full setup — prereqs, theming, your first render.
Usage
Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.
'use client';
import { SplitButton } from '@oshon-ai/components';
export default function Example() {
return <SplitButton />;
}Live 3-tier menu, every tier
isOpen. All menu features are live — grouped sections, leading icons, shortcut keycaps, toggles, selected status, multi-line items with submenus, and a searchable tier 3.<SplitPlayground />
Main action + alternates (Download)
<FeatureDownloadSplit />
API
Every prop is documented here directly from the component's TypeScript interface. Inherited DOM attributes (aria-*, onClick, style, etc.) work as usual but are omitted from this table.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | — | aria-label for the group wrapper. Default derived from the main label. |
chevronIcon | ReactNode | — | Override the default chevron on the trigger half. |
children | ReactNode | — | Main action label. |
className | string | — | Additional classes merged onto the wrapper. |
disabled | boolean | — | Disables both halves. |
dropdownPermissions | Partial<PermissionContext> | — | Per-half permission override for the dropdown trigger. |
dropdownResource | string | action:dropdown | — |
isOpen | boolean | — | Marks the dropdown trigger as open. Wires `data-state="open"` on the trigger half so the pressed visual (per-tier color-mix) pins while the associated menu is mounted. Also flips `aria-expanded`. Owning the open-state on the consumer (or via a `Popover` wrapper) keeps ButtonSplit decoupled from any specific menu primitive. |
leadingIcon | ReactNode | — | Icon rendered before the main label. Replaced by a spinner when `loading`. |
loading | boolean | — | Replaces the main label with a spinner. |
mainPermissions | Partial<PermissionContext> | — | Per-half permission override for the main action. |
mainResource | string | action:main | Resource names for audit. Default `'action:main'` / `'action:dropdown'`. |
onDropdownClick | ((e: MouseEvent<HTMLButtonElement, MouseEvent>) => void) | — | Click handler for the dropdown trigger half. |
onMainClick | ((e: MouseEvent<HTMLButtonElement, MouseEvent>) => void) | — | Click handler for the main action half. |
permissions | Partial<PermissionContext> | — | Permission gate applied to BOTH halves. For per-half control, use `mainPermissions` + `dropdownPermissions` instead. |
size | enum | m | Visual size. Default `'m'`. |
tier | enum | primary | Visual emphasis tier — orthogonal to layout variant. Applied to both halves. Default `'primary'` (solid fill). See ADR-010. |
Styling
Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.
Passing Tailwind classes
Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.
<SplitButton className="ring-2 ring-offset-2 ring-blue-500" />
Data attributes
Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.
| Attribute | Values | Description |
|---|---|---|
data-oshon-size | xs · s · m · l · mobile | Visual size axis. Mirrors the `size` prop. |
data-oshon-tier | primary · secondary · tertiary | Visual emphasis tier (Button family). Mirrors the `tier` prop. |
data-oshon-state | enabled · active · error · disabled | Component surface state. Set automatically based on props. |
data-disabled | true · (omitted) | Set when `disabled` is true. Pair with `:disabled` CSS for native input components. |
data-state | open · closed · checked · unchecked · … | Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.). |
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
--oshon-color-primary-700: var(--my-brand-color);
}Interactive states
Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.
:hover/hover:*— pointer hover:focus-visible/focus-visible:*— keyboard focus:active/active:*— pressed:disabled/disabled:*— set via thedisabledprop
Anatomy
The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.
leadingIconIcon rendered before the label. (Hug / Fill / Dropdown accept it; IconText renames it to `icon` and requires it; IconOnly exposes `icon` as the only content.) Replaced by a spinner when `loading` is true.
childrenButton label. Wrapped in a <span data-oshon-slot='label'>. IconOnly does not accept children — use `icon` + `aria-label` instead.
trailingIconIcon rendered after the label. Hug / Fill / IconText accept it. Dropdown hardcodes a chevron here (overridable via `chevronIcon`).
notification-dotIconOnly only. Small dot in the top-right corner when `notification` is true. Decorative only — aria-label still carries the accessible name.
Keyboard
Enter: activate. Space: activate. Tab: focus. Shift+Tab: focus backward. Split variant: Tab walks main → trigger. All variants emit native <button> semantics. IconOnly requires aria-label.
Accessibility
Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.
- WCAG level
- 2.2 AA
- Screen readers tested
- VoiceOver (macOS), NVDA (Windows)
- Last axe audit
- 2026-04-20
Do / Don't
✓ Do
<ButtonHug onClick={handleSave}>Save</ButtonHug>// Phase 4a consumers: Button is still exported as an alias for ButtonHug.
import { Button } from '@oshon-ai/components';
<Button>Save</Button><ButtonFill onClick={subscribe}>Subscribe</ButtonFill><ButtonDropdown ariaHasPopup="menu" onClick={openMenu}>Actions</ButtonDropdown>✗ Don't
<ButtonHug className="bg-blue-600">Save</ButtonHug>
Breaks white-labeling. All colors must flow through @oshon-ai/tokens — if you need a different surface, add a variant to the manifest and compose it via tokens. See OSHON design principle #6.
<ButtonIconOnly icon={<BellIcon />} />The variant is typed to require aria-label; TypeScript won't compile without it. A screen-reader user hears only 'button' without the label, which is a WCAG 2.2 1.3.1 violation.
<div role="button" onClick={fn}>Save</div>Loses keyboard support, form submission, permission/audit plumbing, and AT defaults. Always use a Button variant.
<ButtonDropdown>{isOpen && <Menu />}</ButtonDropdown>Button is the trigger only. Portal the actual menu/listbox/popover outside the button — nesting it inside breaks focus management and creates duplicate ARIA relationships. Pair ButtonDropdown with <Popover>, <Select>, or a Menu primitive.
Design rationale
Phase 4b fan-out from the 4a Button template per ADR-002. The shared `button-shared.tsx` module is the single source of truth for the size scale + filled surface + spinner + chevron; each variant file differs only in layout (w-full, square, two halves) and slot contract (children optional vs. required). Keeping one manifest for the family — rather than one per variant — matches the mental model consumers have ("it's all Button") and avoids 90%-duplicated manifest documents. File-level variant split is what gives tree-shaking its win; manifest-level unification is what gives docs their coherence.