Lunia DS

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.

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 select

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
valuestring | nullundefinedControlled chosen value.
defaultValuestring | nullnullChosen 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.
labelstringundefinedLabel above the field. Without it, pass aria-label.
placeholderstring"Select an option"Text shown while nothing is chosen.
hintstringundefinedHelp line under the field. When invalid, pass the error text here.
helpTextstringundefinedShows the help icon next to the label with this text in a tooltip.
requiredbooleanfalseA choice is needed before the form is sent; shows the * after the label.
invalidbooleanfalseError border, error icon and a red hint (Figma Destructive).
disabledbooleanfalseFades the field to 50% and turns it off.
maxHeightnumber320List height in px before it scrolls.
namestringundefinedForm field name.
EventDescription
valueChangeA new option was chosen. Payload: its value.
openChangeThe list opened or closed. Payload: the new open state.
SlotDescription
optionScoped slot to draw an option from its item; the check stays in place.
hintRich 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
SEL1hover the triggertrigger borderborder-colorborder-primary → border-control_hoverduration-sm (200)ease-outSwaps at once
SEL2focus, or opentriggerborder-color, box-shadowborder-primary, shadow-xs → border-brand, focus-ring-shadow-xsduration-2xs (120)ease-outShows at once
SEL3openlist panelopacity0 → 1duration-sm (200)ease-outShows at once
SEL4openlist panel (transform origin at the trigger)scale, translate toward the triggerscale-press, -distance-rise → 1, 0duration-md (260)ease-settleShows at once
SEL5openoptionsopacity0 → 1duration-sm (200)ease-outdelay 60msShows at once
SEL6close (choose, Escape, outside click)list panel and optionsopacity, scale1, 1 → 0, scale-pressduration-xs (160)ease-exitHides at once
SEL7open -> true / falsechevronrotate0deg → 180degduration-lg (320)ease-outSwaps at once
SEL8an option is highlighted (pointer or arrow keys)option rowbackground-colortransparent → bg-primary_hoverduration-2xs (120)ease-outSwaps at once
SEL9an option is chosencheck iconopacity, scale0, scale-swap → 1, 1duration-sm (200)ease-outShows at once
SEL10the chosen value changestrigger valueopacity (crossfade)old value 1 -> 0 → new value 0 -> 1duration-sm (200)ease-outSwaps at once
SEL11invalid -> truetrigger border, error icon, hintborder-color, opacity, colorborder-primary, 0, text-tertiary → border-error, 1, text-error-primaryduration-sm (200)ease-outSwaps 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.