Preview
Drives currency + tax defaults.
Set by your workspace admin.
Region is required to continue.
Typing is a no-op; panel always shows every option.
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 dropdown
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 { Dropdown } from '@oshon-ai/components';
export default function Example() {
return <Dropdown />;
}Basic — controlled
Pick your stack.
<BasicField />
State matrix — Desktop (m)
Enabled
Optional Description
Filled
Optional Description
ErrorEmpty
Mandatory Error Description
ErrorFilled
Mandatory Error Description
DisabledEmpty
Optional Description
DisabledFilled
Optional Description
<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
Mandatory Error Description
<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.
<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.
<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.
| 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.
labelField label (ReactNode). Desktop: Lato Medium 12/16 on-surface-muted. Mobile: Lato Regular 14/18 on-surface-muted.
tooltipOptional info-icon slot next to the required asterisk.
secondaryActionRight-justified slot inside the label row.
optionsArray of `{ value, label, disabled? }`. Each becomes a `role="option"` row in the popover panel; `label` is the filter target.
placeholderShown when nothing is selected AND the input is empty. Mirrors the native `<input placeholder>` contract.
descriptionHelper message under the field. Suppressed when `error` is present.
errorError message under the field. When truthy, sets aria-invalid=true and overrides description.
noResultsMessageEmpty-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
<Dropdown label="Framework" placeholder="Select…" options={[{value:"react",label:"React"},{value:"vue",label:"Vue"}]} /><Dropdown label="Region" value={region} onChange={setRegion} options={regions} /><Dropdown label="Role" required description="Your role in the org." options={roleOptions} /><Dropdown label="Region" error="Region is required." options={regionOptions} />✗ Don't
<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.
<Dropdown label="X" required aria-invalid />
Dropdown drives `aria-required` from `required` and `aria-invalid` from `error`. Manual wiring fights the state machine.
<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.
<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>`.