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

Select

Native-feeling single-select dropdown — accessible, search-friendly.

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 select

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

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

Default

tsx
<Select.Root>
      <Select.Trigger size="m" aria-label="Framework">
        <Select.Value placeholder="Select…" />
      </Select.Trigger>
      <Select.Content>
        <Select.Item value="react">React</Select.Item>
        <Select.Item value="vue">Vue</Select.Item>
        <Select.Item value="svelte">Svelte</Select.Item>
      </Select.Content>
    </Select.Root>

Size matrix

tsx
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
      <div key="xs" data-story-size="xs">
        <Select.Root>
          <Select.Trigger size="xs" aria-label="Framework">
            <Select.Value placeholder="Select…" />
          </Select.Trigger>
          <Select.Content>
            <Select.Item value="react">React</Select.Item>
            <Select.Item value="vue">Vue</Select.Item>
            <Select.Item value="svelte">Svelte</Select.Item>
          </Select.Content>
        </Select.Root>
      </div>
      <div key="s" data-story-size="s">
        <Select.Root>
          <Select.Trigger size="s" aria-label="Framework">
            <Select.Value placeholder="Select…" />
          </Select.Trigger>
          <Select.Content>
            <Select.Item value="react">React</Select.Item>
            <Select.Item value="vue">Vue</Select.Item>
            <Select.Item value="svelte">Svelte</Select.Item>
          </Select.Content>
        </Select.Root>
      </div>
      <div key="m" data-story-size="m">
        <Select.Root>
          <Select.Trigger size="m" aria-label="Framework">
            <Select.Value placeholder="Select…" />
          </Select.Trigger>
          <Select.Content>
            <Select.Item value="react">React</Select.Item>
            <Select.Item value="vue">Vue</Select.Item>
            <Select.Item value="svelte">Svelte</Select.Item>
          </Select.Content>
        </Select.Root>
      </div>
      <div key="l" data-story-size="l">
        <Select.Root>
          <Select.Trigger size="l" aria-label="Framework">
            <Select.Value placeholder="Select…" />
          </Select.Trigger>
          <Select.Content>
            <Select.Item value="react">React</Select.Item>
            <Select.Item value="vue">Vue</Select.Item>
            <Select.Item value="svelte">Svelte</Select.Item>
          </Select.Content>
        </Select.Root>
      </div>
      <div key="mobile" data-story-size="mobile">
        <Select.Root>
          <Select.Trigger size="mobile" aria-label="Framework">
            <Select.Value placeholder="Select…" />
          </Select.Trigger>
          <Select.Content>
            <Select.Item value="react">React</Select.Item>
            <Select.Item value="vue">Vue</Select.Item>
            <Select.Item value="svelte">Svelte</Select.Item>
          </Select.Content>
        </Select.Root>
      </div>
    </div>

Permission denied

tsx
<Select.Root permissions={{ can: () => false }}>
      <Select.Trigger size="m" aria-label="Framework">
        <Select.Value placeholder="Select…" />
      </Select.Trigger>
      <Select.Content>
        <Select.Item value="react">React</Select.Item>
        <Select.Item value="vue">Vue</Select.Item>
        <Select.Item value="svelte">Svelte</Select.Item>
      </Select.Content>
    </Select.Root>

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

Trigger

The form-field surface. Takes `size="xs|s|m|l|mobile"` and displays either Value (selected) or the placeholder (unset).

Value

Pass-through of the primitive Value; Radix chooses between selected text and `[data-placeholder]` span styled via the Trigger.

Icon

Trailing chevron/indicator. Rotates 180° when the listbox is open via `data-state="open"`.

Content

Listbox surface rendered through a portal. Width matches the Trigger (`--radix-select-trigger-width`); height caps at the available viewport space.

Item

Single selectable row. Highlight state driven by `data-highlighted`; disabled state by `data-disabled`.

ItemIndicator

Checkmark/indicator slot shown only on the selected Item.

Separator

Thin divider between Item groups.

Label

Caption heading above an item group; uppercase small-caps treatment.

Keyboard

Space/Enter: open listbox. Up/Down: move highlight (typeahead supported). Enter: commit. Esc: close without committing. Tab: close + move focus. (Behavior inherited from @radix-ui/react-select via @oshon-ai/primitives/select.)

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
<Select.Root defaultValue="open">
  <Select.Trigger aria-label="Status">
    <Select.Value placeholder="Select…" />
    <Select.Icon>▾</Select.Icon>
  </Select.Trigger>
  <Select.Portal>
    <Select.Content>
      <Select.Viewport>
        <Select.Item value="open">
          <Select.ItemText>Open</Select.ItemText>
          <Select.ItemIndicator>✓</Select.ItemIndicator>
        </Select.Item>
        <Select.Item value="closed">
          <Select.ItemText>Closed</Select.ItemText>
          <Select.ItemIndicator>✓</Select.ItemIndicator>
        </Select.Item>
      </Select.Viewport>
    </Select.Content>
  </Select.Portal>
</Select.Root>
Grouped options
<Select.Viewport>
  <Select.Group>
    <Select.Label>Active</Select.Label>
    <Select.Item value="open">…</Select.Item>
    <Select.Item value="in-review">…</Select.Item>
  </Select.Group>
  <Select.Separator />
  <Select.Group>
    <Select.Label>Archived</Select.Label>
    <Select.Item value="closed">…</Select.Item>
  </Select.Group>
</Select.Viewport>
Sized Trigger
<Select.Trigger size="l">…</Select.Trigger>

✗ Don't

Using Select for many options (>~50)
<Select.Viewport>{thousandsOfItems.map(...)}</Select.Viewport>

Select is a listbox pattern sized for small enumerations. For density regimes beyond `card`/`standard` (see ARCHITECTURE §2) use the Combobox / search-backed picker in @oshon-ai/data (Phase 5).

Rendering Content outside Portal
<Select.Root><Select.Content>…</Select.Content></Select.Root>

Content must live inside Portal so the listbox escapes any ancestor with `overflow: hidden` or a lower stacking context; otherwise it clips and breaks collision detection.

Design rationale

Phase 4a anchor for list-based form fields. Closes the flagship trio (Button + Dialog + Select). Proves the styled-compound pattern for Radix-backed primitives with many sub-components, and pins the listbox surface styling (radius, shadow, z-index, trigger-width binding) that Combobox + Menu + Popover-picker will inherit in 4b+. See ADR-001, ADR-002, ADR-003.