Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
ActionsUpdatedFreeWCAG 2.2 AA

SplitButton

Split button (action + dropdown).

Preview

Live preview
@oshon-ai/components

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add splitbutton

Wire the tokens into your Tailwind v4 stylesheet:

css
/* 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.

tsx
'use client';
import { SplitButton } from '@oshon-ai/components';

export default function Example() {
  return <SplitButton />;
}

Live 3-tier menu, every tier

Main action fired:
Last menu action:
Status: active
Compact view: on
Show archived: off
Each ButtonSplit pairs a direct main action with a full 3-tier overflow menu. Click the main half to fire the primary action; click the chevron half to open the menu. The trigger pins its pressed visual while the menu is mounted via 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.
tsx
<SplitPlayground />

Main action + alternates (Download)

Last download:
Classic split-button pattern: the main half fires a default action, the chevron half opens the menu of alternates.
tsx
<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.

PropTypeDefaultDescription
aria-labelstringaria-label for the group wrapper. Default derived from the main label.
chevronIconReactNodeOverride the default chevron on the trigger half.
childrenReactNodeMain action label.
classNamestringAdditional classes merged onto the wrapper.
disabledbooleanDisables both halves.
dropdownPermissionsPartial<PermissionContext>Per-half permission override for the dropdown trigger.
dropdownResourcestringaction:dropdown
isOpenbooleanMarks 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.
leadingIconReactNodeIcon rendered before the main label. Replaced by a spinner when `loading`.
loadingbooleanReplaces the main label with a spinner.
mainPermissionsPartial<PermissionContext>Per-half permission override for the main action.
mainResourcestringaction:mainResource 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.
permissionsPartial<PermissionContext>Permission gate applied to BOTH halves. For per-half control, use `mainPermissions` + `dropdownPermissions` instead.
sizeenummVisual size. Default `'m'`.
tierenumprimaryVisual 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.

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

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* 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 the disabled prop

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.

leadingIcon

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

children

Button label. Wrapped in a <span data-oshon-slot='label'>. IconOnly does not accept children — use `icon` + `aria-label` instead.

trailingIcon

Icon rendered after the label. Hug / Fill / IconText accept it. Dropdown hardcodes a chevron here (overridable via `chevronIcon`).

notification-dot

IconOnly 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

Basic (Hug)
<ButtonHug onClick={handleSave}>Save</ButtonHug>
Back-compat alias
// Phase 4a consumers: Button is still exported as an alias for ButtonHug.
import { Button } from '@oshon-ai/components';
<Button>Save</Button>
Full-width CTA (Fill)
<ButtonFill onClick={subscribe}>Subscribe</ButtonFill>
Menu trigger (Dropdown)
<ButtonDropdown ariaHasPopup="menu" onClick={openMenu}>Actions</ButtonDropdown>

✗ Don't

Hardcoded color override
<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.

IconOnly without aria-label
<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.

Wrapping in a <div role="button">
<div role="button" onClick={fn}>Save</div>

Loses keyboard support, form submission, permission/audit plumbing, and AT defaults. Always use a Button variant.

Nesting dropdown logic inside Button
<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.