Components · Forms & selection
Select
A styled native <select> — accessible and keyboard-operable for free — with a custom chevron. The trigger matches the text input; the option list is the platform's.
Preview
Build with the skill
No package to install — hand this to your AI to generate or migrate the component in your stack.
Apply the Zetta "Select" component to this project using the zetta-design-md skill.
WHEN TO USE
- Migrating or fixing existing code (the common case): audit the current implementation against the `select` spec (variants: default, focus, invalid, disabled) and CORRECT every deviation — replace off-spec values, don't only add new ones.
- New component: build it to the `select` spec in this project's stack.
AUDIT & FIX (when the component already exists)
Compare it to the skill's spec and fix, don't just append: hardcoded colors/hex, off-scale radius, wrong fonts, dark/navy fills, shadows used in place of borders (or missing borders), brand lime used as a CTA or interaction (must be primary indigo), and any missing or wrong hover / focus / active / disabled / invalid state or missing 2px focus ring. Replace each with the correct Zetta token.
ALWAYS
- Pull the authoritative spec and tokens from the skill — never guess or hardcode values.
- Use this project's own component conventions and framework idioms.
- Honor the Zetta guardrails (brand = identity only, primary = interaction, shadows for overlays) and verify in Light, Dark, and Accessibility. Anatomy & rules
╭─────────────────────────────╮
│ Option label ▾ │ ← select-control
╰─────────────────────────────╯
╭─────────────────────────────╮
│ 🔍 Search... │ ← select-input (when searchable)
│ ─────────────────────────── │
│ Option A │ ← select-option
│ Option B ✓ │ ← select-option-selected
│ Option C │
╰─────────────────────────────╯
| State | Trigger border | Fill | Notes |
|---|---|---|---|
| Default (empty) | {colors.border-strong} |
{colors.surface-card} |
Placeholder shown |
| Default (has value) | {colors.border-strong} |
{colors.surface-card} |
Selected value shown |
| Hover | {colors.muted} |
{colors.surface-card} |
— |
| Focus / Open | {colors.primary} + glow |
{colors.surface-card} |
Dropdown panel appears |
| Disabled | {colors.disabled-border} |
{colors.disabled-bg} |
not-allowed |
| Error | {colors.danger} + glow |
{colors.surface-card} |
Validation message |
| Read-only | {colors.hairline} |
{colors.surface-muted} |
No dropdown opens |
When to use
- Standard: short static option lists (< 20 items).
- Async: long or server-side option sets, user search is needed.
- Creatable / Async Creatable: user needs to define new values on the fly (tags, labels, assignees).
- Popup: compact toolbar filters, column visibility toggles.
- Checkbox Select: multi-select where users compare options — checkboxes make selection state explicit.
- Radio Select: constrained single choice where the radio pattern communicates mutual exclusivity.
When not to use
- 2–4 options — use Radio Group or Segmented Control instead.
- Simple yes/no — use Toggle.
Do
- Always provide a
placeholderwith a meaningful prompt ("Select assignee"). - Show group headings for option lists with 3+ categories.
- For async selects, display a loading indicator while options fetch.
Don't
- Don't use Creatable when the option set is closed — it confuses users about whether free text is valid.
- Placeholder: action-oriented prompt, 2–4 words. "Select assignee", "Choose a project", "Filter by status". Never "Select…" alone.
- Option labels: concise noun phrases. Sentence case. Avoid special characters except where semantically required (e.g. status codes).
- Group headings: short uppercase nouns — "DEPARTMENTS", "ROLES". Keep under 20 characters.
- Creatable placeholder: "Create option" or "Add [noun]" — make it clear the user is creating, not selecting.
- No options message: "No options" (default) or context-specific: "No assignees found", "No results for 'abc'".
Keyboard
| Key | Behavior |
|---|---|
Tab |
Move focus to the select trigger |
Enter / Space |
Open the dropdown |
Up / Down Arrow |
Navigate options |
Home / End |
Jump to first / last option |
Type characters |
Jump to option matching typed prefix |
Enter |
Select focused option, close dropdown |
Escape |
Close without selecting |
Backspace |
Remove last multi-value chip |
Accessibility
- ARIA:
role="combobox"on the control;aria-expandedtoggles on open/close;aria-haspopup="listbox". - Menu:
role="listbox"; each optionrole="option"witharia-selected. - Multi-value chips: each chip should have
aria-label="Remove [value]"on its remove button. - Async loading: announce loading state with
aria-live="polite"region.
Spec
Values and token references resolved straight from the Zetta spec.
select-container
- position
- relative
- width
- 100%
select-control
- display
- flex
- alignItems
- center
- justifyContent
- space-between
- paddingX
- {spacing.sm}
- paddingY
- {spacing.xs}
- minHeight
- 36px
- backgroundColor
- {colors.surface-card}
- borderColor
- {colors.hairline}
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 400
- lineHeight
- 1.40
- textColor
- {colors.ink}
- cursor
- pointer
- gap
- {spacing.xs}
select-control-hover
- borderColor
- {colors.border-strong}
select-control-focus
- borderColor
- {colors.border-focus}
- outline
- 2px solid {colors.border-focus}
- outlineOffset
- -1px
select-control-disabled
- backgroundColor
- {colors.surface-muted}
- borderColor
- {colors.hairline}
- textColor
- {colors.disabled-text}
- cursor
- not-allowed
select-control-error
- borderColor
- {colors.danger}
select-placeholder
- textColor
- {colors.muted}
select-value
- textColor
- {colors.ink}
- overflow
- hidden
- textOverflow
- ellipsis
- whiteSpace
- nowrap
select-multi-value
- backgroundColor
- {colors.primary-bg}
- borderRadius
- {rounded.sm}
- paddingX
- {spacing.xs}
- paddingY
- 2px
- fontSize
- 12px
- textColor
- {colors.primary}
- display
- inline-flex
- alignItems
- center
- gap
- {spacing.xs}
- margin
- 2px
select-multi-value-remove
- iconSize
- {icons.sizeMap.sm}
- iconColor
- {colors.primary}
- cursor
- pointer
select-indicator-separator
- width
- 1px
- backgroundColor
- {colors.hairline}
- alignSelf
- stretch
- marginY
- 4px
select-dropdown-indicator
- iconSize
- {icons.sizeMap.md}
- iconColor
- {colors.muted}
- padding
- {spacing.xs}
select-clear-indicator
- iconSize
- {icons.sizeMap.md}
- iconColor
- {colors.muted}
- cursor
- pointer
- padding
- {spacing.xs}
select-menu
- backgroundColor
- {colors.popover}
- borderColor
- {colors.hairline}
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- shadow
- {elevation.shadow.overlay}
- marginTop
- 4px
- zIndex
- {elevation.z-index.popup}
- overflow
- hidden
select-menu-list
- maxHeight
- 300px
- overflowY
- auto
- padding
- {spacing.xs}
select-option
- paddingX
- {spacing.sm}
- paddingY
- {spacing.xs}
- borderRadius
- {rounded.sm}
- fontSize
- 14px
- fontFamily
- Geist
- textColor
- {colors.ink}
- cursor
- pointer
- display
- flex
- alignItems
- center
- gap
- {spacing.sm}
- minHeight
- 32px
select-option-hover
- backgroundColor
- {colors.primary-bg}
- textColor
- {colors.primary}
select-option-selected
- backgroundColor
- {colors.primary-bg}
- textColor
- {colors.primary}
- fontWeight
- 500
select-option-focused
- backgroundColor
- {colors.primary-bg}
- textColor
- {colors.primary}
- outline
- 2px solid {colors.border-focus}
- outlineOffset
- -2px
select-option-disabled
- textColor
- {colors.disabled-text}
- cursor
- not-allowed
select-group-heading
- fontSize
- 11px
- fontWeight
- 500
- textColor
- {colors.muted}
- textTransform
- uppercase
- letterSpacing
- 0.06em
- paddingX
- {spacing.sm}
- paddingY
- {spacing.xs}
select-input
- fontFamily
- Geist
- fontSize
- 14px
- border
- none
- outline
- none
- backgroundColor
- transparent
- textColor
- {colors.ink}
- flexGrow
- 1
- minWidth
- 80px
select-no-options
- paddingX
- {spacing.sm}
- paddingY
- {spacing.md}
- textColor
- {colors.muted}
- fontSize
- 14px
- textAlign
- center
select-loading
- paddingX
- {spacing.sm}
- paddingY
- {spacing.md}
- textColor
- {colors.muted}
- fontSize
- 14px
select-create-option
- fontStyle
- italic
- textColor
- {colors.primary}
select-popup-trigger
- display
- inline-flex
- alignItems
- center
- gap
- {spacing.xs}
- paddingX
- {spacing.sm}
- paddingY
- {spacing.xs}
- borderRadius
- {rounded.base}
- fontSize
- 14px
- fontFamily
- Geist
- textColor
- {colors.ink}
- borderColor
- {colors.hairline}
- borderWidth
- 1px
- backgroundColor
- {colors.surface-card}
- cursor
- pointer
select-popup-trigger-active
- borderColor
- {colors.border-focus}
- backgroundColor
- {colors.primary-bg}
- textColor
- {colors.primary}
select-checkbox-option
- display
- flex
- alignItems
- center
- gap
- {spacing.sm}
select-radio-option
- display
- flex
- alignItems
- center
- gap
- {spacing.sm}
Known gaps
- Combobox fixed-height variant (fixed menu height + internal overflow-y scroll) — not yet specified.
- Popup select placement fallback — flip behavior for constrained viewports not yet specified.
- Group keyboard navigation — arrow key behavior across group boundaries not yet documented.