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

Dropdown

Typeahead combobox — input doubles as search · 8 states · 5 sizes · WAI-ARIA 1.2.

Preview

Live preview
@oshon-ai/components
Long list — type to filter (try 'a' or 'united')
With description

Drives currency + tax defaults.

Required + tooltip
Disabled

Set by your workspace admin.

Error
Filter disabled — searchable={false}

Typing is a no-op; panel always shows every option.

Sizes — xs · s · m · l · mobile

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 dropdown

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

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

Basic — controlled

Pick your stack.

Value: (empty)
tsx
<BasicField />

State matrix — Desktop (m)

Enabled

Optional Description

Filled

Optional Description

ErrorEmpty

ErrorFilled

DisabledEmpty

Optional Description

DisabledFilled

Optional Description

tsx
<div style={stackCol}>
      <p style={sectionHeading}>Enabled</p>
      <Dropdown label="Label" required tooltip description="Optional Description" options={OPTIONS} placeholder="Select…" />

      <p style={sectionHeading}>Filled</p>
      <Dropdown label="Label" required tooltip description="Optional Description" options={OPTIONS} defaultValue="react" />

      <p style={sectionHeading}>ErrorEmpty</p>
      <Dropdown label="Label" required tooltip error="Mandatory Error Description" options={OPTIONS} placeholder="Select…" />

      <p style={sectionHeading}>ErrorFilled</p>
      <Dropdown label="Label" required tooltip defaultValue="vue" error="Mandatory Error Description" options={OPTIONS} />

      <p style={sectionHeading}>DisabledEmpty</p>
      <Dropdown label="Label" required tooltip description="Optional Description" options={OPTIONS} placeholder="Select…" disabled />

      <p style={sectionHeading}>DisabledFilled</p>
      <Dropdown label="Label" required tooltip description="Optional Description" options={OPTIONS} defaultValue="svelte" disabled />
    </div>

State matrix — Mobile

Enabled

Optional Description

Filled

Optional Description

ErrorFilled

tsx
<div style={stackCol}>
      <p style={sectionHeading}>Enabled</p>
      <Dropdown size="mobile" label="Label" required tooltip description="Optional Description" options={OPTIONS} placeholder="Select…" />
      <p style={sectionHeading}>Filled</p>
      <Dropdown size="mobile" label="Label" required tooltip description="Optional Description" options={OPTIONS} defaultValue="react" />
      <p style={sectionHeading}>ErrorFilled</p>
      <Dropdown size="mobile" label="Label" required tooltip defaultValue="vue" error="Mandatory Error Description" options={OPTIONS} />
    </div>

Size ladder — xs → mobile

Proportional to Figma scale.

Proportional to Figma scale.

Proportional to Figma Desktop.

Proportional to Figma scale.

Proportional to Figma Mobile.

tsx
<SizeLadderPlayground />

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

label

Field label (ReactNode). Desktop: Lato Medium 12/16 on-surface-muted. Mobile: Lato Regular 14/18 on-surface-muted.

tooltip

Optional info-icon slot next to the required asterisk.

secondaryAction

Right-justified slot inside the label row.

options

Array of `{ value, label, disabled? }`. Each becomes a `role="option"` row in the popover panel; `label` is the filter target.

placeholder

Shown when nothing is selected AND the input is empty. Mirrors the native `<input placeholder>` contract.

description

Helper message under the field. Suppressed when `error` is present.

error

Error message under the field. When truthy, sets aria-invalid=true and overrides description.

noResultsMessage

Empty-state row rendered in the panel when filtering produces zero matches. Default `"No matches"`.

Keyboard

Click input / ArrowDown opens. Type to filter (case-insensitive substring on `label`). ArrowDown/ArrowUp cycle the highlight, wrapping; Home/End jump to first/last enabled. Enter commits the highlighted option; Tab commits while editing and closes otherwise. Escape closes + reverts to the previously-selected label. Disabled options are skipped during nav and unclickable. `aria-activedescendant` carries the highlight without moving DOM focus.

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-05-18

Do / Don't

✓ Do

Basic uncontrolled
<Dropdown label="Framework" placeholder="Select…" options={[{value:"react",label:"React"},{value:"vue",label:"Vue"}]} />
Controlled with onChange
<Dropdown label="Region" value={region} onChange={setRegion} options={regions} />
Required with description
<Dropdown label="Role" required description="Your role in the org." options={roleOptions} />
Error
<Dropdown label="Region" error="Region is required." options={regionOptions} />

✗ Don't

Mounting Dropdown inside a popover that swallows pointer events
<Popover open><Dropdown options={…} /></Popover>

Dropdown owns its own click-outside listener anchored to its root. Nesting it inside another popover that captures pointer events confuses both — extract the trigger or use the Menu primitive directly.

Forwarding native `required` + `aria-invalid` together
<Dropdown label="X" required aria-invalid />

Dropdown drives `aria-required` from `required` and `aria-invalid` from `error`. Manual wiring fights the state machine.

Rendering both description AND error
<Dropdown label="X" description="Help" error="Bad" options={[]} />

Figma contract is exactly one helper slot: description xor error. When `error` is present, description is suppressed.

Using Dropdown for a single-letter typeahead in a tiny list
<Dropdown options={[{value:"y",label:"Yes"},{value:"n",label:"No"}]} />

For 2-3 option pickers a `RadioGroup` or native `<select>` is faster to scan and uses less vertical space. Combobox semantics are overkill below ~5 options.

Design rationale

Phase 4e.3 visual fidelity refit, rewritten 2026-05-18 from a native-select wrapper to a real WAI-ARIA Combobox. The trigger doubling as the search input is the most-requested UX from users who hit the native-select wall on lists over ~10 options — typing should narrow the list, not jump character-by-character. Focus stays on the input via `aria-activedescendant`, which is the canonical APG combobox pattern; option clicks intercept mousedown to keep focus on the input across the selection. The chevron is a real `<button>` with `tabIndex=-1` so it never steals tab order. The field visual matches the previous native-select implementation exactly (same border, focus, hover, error tokens) so existing screenshots and Figma references stay accurate. Backward-compatible API: `value` / `defaultValue` / `onChange` keep the same names — `onChange` is now `(value: string) => void` instead of an event since the field is no longer a `<select>`.