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

DropdownButton

Button with dropdown chevron.

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 dropdownbutton

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 { DropdownButton } from '@oshon-ai/components';

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

Live 3-tier menu, every tier

Last action:
Status: active
Compact view: on
Show archived: off
Each Actions ▾ trigger opens the same 3-tier menu so you can compare tier chrome with an identical surface: grouped sections, leading icons, shortcut keycaps, toggles, single-select status, multi-line Pricing rule with submenus, searchable teammate picker, and a disabled Delete. Toggles flip inline without closing the menu; picking a leaf bubbles to onAction and dismisses the tree.
tsx
<DropdownPlayground />

Single-select

Single-select radio via selected: the current sort field gets a check + primary-50 tint. Picking any row updates the trigger label and closes the menu.
tsx
<FeatureSelectedSort />

Toggles that don\

Word wrap: on
Line numbers: off
Minimap: on
All three rows are toggle items — flipping one keeps the menu open so users can batch several changes. No onAction bubble.
tsx
<FeatureToggleView />

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
children*ReactNodeButton contents. Unstyled — wrap with icons / labels as needed. The visual layer in `@oshon-ai/components` adds spacing and iconography on top of this primitive.
ariaHasPopupenummenuPopover role the trigger controls. Default `'menu'`; pass `'listbox'` if the trigger opens a Select-style listbox, or `'dialog'` for a Popover-hosted mini-form.
chevronIconReactNodeOverride the default chevron. Useful for overflow menus (e.g. `more-horizontal`) or when the paired popover communicates a non-menu affordance.
classNamestringAdditional classes merged after the component's default classes.
disabledbooleanDisable the button. Renders with `aria-disabled="true"` and the native `disabled` attribute so screen readers and keyboard users get consistent signal. When `disabled`, `onClick` does not fire and no audit event is emitted.
leadingIconReactNodeIcon rendered before the label. Replaced by a spinner when `loading`.
loadingbooleanIn-flight state. Sets `aria-busy="true"` + `disabled` so assistive tech announces the button as unavailable while the action resolves. The visual layer renders a spinner on top; the primitive itself does not.
permissionAttrsRecord<string, unknown>Attribute bag forwarded to `permissions.can(..., attrs)` for attribute-based access control (row ID, tenant, owner). Primitives never invent values here — the consumer supplies them.
permissionsPartial<PermissionContext>Per-instance permission override. Merges over the ambient `PermissionContext`. Example: `permissions={{ mode: 'hidden' }}` switches this one instance to render-as-null when denied.
resourcestringResource name passed to `permissions.can(action, resource, attrs)`. Default `'button'`. Consumers override for specific semantics, e.g. `resource="row:edit"` for a row-level action button.
sizeenummVisual size. Default `'m'`.
tierenumprimaryVisual emphasis tier — orthogonal to layout variant. Default `'primary'` (solid fill). See ADR-010.
typeenumNative `type` attribute. Defaults to `'button'` so the primitive never accidentally submits a form. Pass `'submit'` explicitly for form buttons.

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