Base components
Select
A field that opens a list to pick one option, where the list grows out of the field and its options fade in just after it, and the chosen option gets a check.
Live preview
Loading preview
Team member pickers in the four types (default, icon, avatar with @handle as supporting text, status dot), a required invalid select with "Select a team member to continue.", and a disabled one. Replay opens the avatar select, moves down two options and chooses one.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add selectUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { Select, type SelectOption } from './select';
const PEOPLE: SelectOption[] = [
{ value: 'olivia', label: 'Olivia Rhye', supportingText: '@olivia' },
{ value: 'phoenix', label: 'Phoenix Baker', supportingText: '@phoenix' },
{ value: 'lana', label: 'Lana Steiner', supportingText: '@lana' },
{ value: 'demi', label: 'Demi Wilkinson', supportingText: '@demi' },
{ value: 'candice', label: 'Candice Wu', supportingText: '@candice' },
{ value: 'natali', label: 'Natali Craig', supportingText: '@natali', disabled: true },
];
const STATUS: SelectOption[] = [
{ value: 'olivia', label: 'Olivia Rhye', supportingText: 'Online', dot: 'fg-success-secondary' },
{ value: 'phoenix', label: 'Phoenix Baker', supportingText: 'Away', dot: 'fg-warning-secondary' },
{ value: 'lana', label: 'Lana Steiner', supportingText: 'Offline', dot: 'fg-quaternary' },
];
function UserIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.67"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 10a3.333 3.333 0 1 0 0-6.667A3.333 3.333 0 0 0 10 10Zm-6.667 6.667c0-2.762 2.985-5 6.667-5s6.667 2.238 6.667 5" />
</svg>
);
}
/** Sends a key to the element that has focus, as the keyboard would. */
function press(key: string) {
const target = document.activeElement ?? document.body;
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
target.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true, cancelable: true }));
}
export default function SelectDemo({ replay = 0 }: { replay?: number }) {
const [open, setOpen] = React.useState(false);
const [owner, setOwner] = React.useState<string | null>('olivia');
React.useEffect(() => {
if (replay === 0) return;
setOwner(null);
const timers = [
window.setTimeout(() => setOpen(true), 0),
// Base UI opens with no highlight, so the first key lands on the first option.
window.setTimeout(() => press('ArrowDown'), 500),
window.setTimeout(() => press('ArrowDown'), 800),
window.setTimeout(() => press('ArrowDown'), 1100),
window.setTimeout(() => press('Enter'), 1500),
];
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div className="grid w-full max-w-2xl gap-6 sm:grid-cols-2">
<Select label="Team member" items={PEOPLE} defaultValue="olivia" placeholder="Select team member" />
<Select
label="Team member"
type="icon-leading"
icon={<UserIcon />}
items={PEOPLE}
placeholder="Select team member"
/>
<Select
label="Owner"
type="avatar-leading"
items={PEOPLE}
value={owner}
onValueChange={setOwner}
open={open}
onOpenChange={setOpen}
placeholder="Select team member"
helpText="The owner gets a message when the task changes."
hint="Select who owns this task."
/>
<Select label="Status" type="dot-leading" items={STATUS} defaultValue="olivia" />
<Select
label="Reviewer"
items={PEOPLE}
required
invalid
placeholder="Select team member"
hint="Select a team member to continue."
/>
<Select label="Team member" items={PEOPLE} disabled placeholder="Select team member" />
</div>
);
}
API
Parts: root (field), label with required mark (*) and help icon, trigger: leading icon, avatar or dot, value or placeholder, supporting text, chevron, list panel (positioner and popup) with scroll bar, option: leading icon, avatar or dot, label, supporting text, check, hint text (becomes the error text when invalid).
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | null | undefined | Controlled chosen value. |
| defaultValue | string | null | null | Chosen value when uncontrolled. |
| items | { value: string; label: string; supportingText?: string; icon?: Component; avatarSrc?: string; dot?: string; disabled?: boolean }[] | [] | Options. dot is a color token name for the status dot. |
| type | "default" | "icon-leading" | "avatar-leading" | "dot-leading" | "default" | Leading content of the trigger and the options. |
| size | "sm" | "md" | "lg" | "md" | 40, 44 or 48px tall, matching Input. |
| label | string | undefined | Label above the field. Without it, pass aria-label. |
| placeholder | string | "Select an option" | Text shown while nothing is chosen. |
| hint | string | undefined | Help line under the field. When invalid, pass the error text here. |
| helpText | string | undefined | Shows the help icon next to the label with this text in a tooltip. |
| required | boolean | false | A choice is needed before the form is sent; shows the * after the label. |
| invalid | boolean | false | Error border, error icon and a red hint (Figma Destructive). |
| disabled | boolean | false | Fades the field to 50% and turns it off. |
| maxHeight | number | 320 | List height in px before it scrolls. |
| name | string | undefined | Form field name. |
| Event | Description |
|---|---|
| valueChange | A new option was chosen. Payload: its value. |
| openChange | The list opened or closed. Payload: the new open state. |
| Slot | Description |
|---|---|
| option | Scoped slot to draw an option from its item; the check stays in place. |
| hint | Rich hint content, replaces the hint prop. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| SEL1 | hover the trigger | trigger border | border-color | border-primary → border-control_hover | duration-sm (200)ease-out | Swaps at once |
| SEL2 | focus, or open | trigger | border-color, box-shadow | border-primary, shadow-xs → border-brand, focus-ring-shadow-xs | duration-2xs (120)ease-out | Shows at once |
| SEL3 | open | list panel | opacity | 0 → 1 | duration-sm (200)ease-out | Shows at once |
| SEL4 | open | list panel (transform origin at the trigger) | scale, translate toward the trigger | scale-press, -distance-rise → 1, 0 | duration-md (260)ease-settle | Shows at once |
| SEL5 | open | options | opacity | 0 → 1 | duration-sm (200)ease-outdelay 60ms | Shows at once |
| SEL6 | close (choose, Escape, outside click) | list panel and options | opacity, scale | 1, 1 → 0, scale-press | duration-xs (160)ease-exit | Hides at once |
| SEL7 | open -> true / false | chevron | rotate | 0deg → 180deg | duration-lg (320)ease-out | Swaps at once |
| SEL8 | an option is highlighted (pointer or arrow keys) | option row | background-color | transparent → bg-primary_hover | duration-2xs (120)ease-out | Swaps at once |
| SEL9 | an option is chosen | check icon | opacity, scale | 0, scale-swap → 1, 1 | duration-sm (200)ease-out | Shows at once |
| SEL10 | the chosen value changes | trigger value | opacity (crossfade) | old value 1 -> 0 → new value 0 -> 1 | duration-sm (200)ease-out | Swaps at once |
| SEL11 | invalid -> true | trigger border, error icon, hint | border-color, opacity, color | border-primary, 0, text-tertiary → border-error, 1, text-error-primary | duration-sm (200)ease-out | Swaps at once |
Accessibility
- Built on a select primitive: the trigger is a combobox-style button with aria-haspopup listbox and aria-expanded; the list is role listbox with role option rows and aria-selected.
- Keyboard: Enter, Space, Down or Up opens; arrows move, Home and End jump, typing a letter jumps to a match, Enter chooses, Escape closes and returns focus to the trigger.
- The label, hint and invalid state are linked as in Input; a hidden native input takes part in forms.
- Avatars and dots are aria-hidden; a dot that shows status needs the status in the option label.
- Disabled options stay in the list with aria-disabled and cannot be chosen.
- Reduced motion: the list shows and hides at once, the chevron and the check swap at once.