# Animated Select (Moonarc)

A native select with an animated picker. Where the engine has appearance: base-select, the picker takes the tokens, the options arrive one after another through @starting-style with a delay from sibling-index(), the list leaves on allow-discrete and the checkmark is drawn. Elsewhere it is the native select with the same border and chevron, no animation. No imitated listbox: the keyboard, type-to-select, the value and the accessibility tree are the browser's. Zero JavaScript.

- Import: `import AnimatedSelect from '@moonarc/core/AnimatedSelect'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/animated-select.json`
- Tier A · category ui · trigger click
- Readout: `<AnimatedSelect name="model" options={models}>`
- Browser support: limited (Chrome 138 · not Firefox · Safari 27); elsewhere: Firefox, and Safari before 27, show the native select styled with the token border and the chevron; the picker is the platform's and nothing animates
- Measured cost: 0 B JS (CSS 7.5 kB raw)
- Page: https://moonarc.dev/components/animated-select/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `options` | `{ value, label, disabled?, group? }[]` | none | The options; those sharing a group go into one <optgroup>, in order of first appearance. |
| `name` | `string` | none | Form field name. |
| `value` | `string` | none | The selected option's value. |
| `label` | `string` | none | Visible label as <label for>. Without it, pass aria-label. |
| `placeholder` | `string` | none | A disabled first option, selected until a choice is made. With required, the form refuses it. |
| `size` | `'sm' | 'md'` | `'md'` | Padding and font size. |
| `id` | `string` | `unique per render` | The select's id, and the label's for. Unset, ma-aselect- with the name and a random suffix, so a PromptInput and an AnimatedSelect both named model can share a page. Yours wins. |

## Usage

```astro
---
const models = [
  { value: 'fable', label: 'Fable 5.1' },
  { value: 'opus', label: 'Opus 5' },
  { value: 'sonnet', label: 'Sonnet 5' },
  { value: 'haiku', label: 'Haiku 4.5', group: 'Small' },
];
---
<AnimatedSelect name="model" label="Model" options={models} value="opus" />
<AnimatedSelect name="size" aria-label="Size" placeholder="Pick a size" size="sm" options={[{ value: 's', label: 'Small' }, { value: 'l', label: 'Large' }]} required />
```

## Reduced motion

The picker appears in place and the options are all there at once.

## With ClientRouter

A native select; nothing to bind.

## Craft

- appearance: base-select is the customizable-select feature: the select stays a select (value, name, required, keyboard, type-to-select, the accessibility tree) and its picker becomes a styleable box (::picker(select)) with styleable options. No imitated listbox with its own key handling and its own bugs.
- The options stagger with one rule: transition-delay is sibling-index() times the stagger token, so the first option is on screen on the first frame of the picker and the sixth follows a fraction later. On hover the delay is zero, so the highlight never lags the pointer.
- The picker enters through @starting-style (a small rise on the preset curve) and leaves through display and overlay on allow-discrete, so the exit is visible and the top layer waits for it.
- Engines without the feature get the same border, radius, panel colour and chevron on the native control, so a form with one of each does not look like two libraries. The picker there is the platform's own; nothing is emulated.
- The chevron is ours in both modes (the native ::picker-icon is hidden) so the two looks share one glyph; under base-select it turns on :open.
- With label it renders two nodes: the <label>, then the wrapper span that holds the select and the chevron. class and style go to the wrapper, every other attribute to the select; wrap the component in an element of your own to place both as one.

## Replaces

- Select (shadcn / Radix)
- AnimatedSelect (Motion Primitives)
- Listbox (Headless UI)

## Source

```astro
---
/**
 * AnimatedSelect — a native <select> that blooms, zero JS. In an engine
 * with `appearance: base-select` the button and the `::picker(select)`
 * take the tokens, the options arrive one after another through
 * @starting-style with a delay from `sibling-index()`, the picker leaves
 * through allow-discrete and `::checkmark` is drawn. Everywhere else it is
 * the native select with a token border and a chevron, no animation — the
 * same set, one browser short of the motion. No listbox is imitated: the
 * keyboard, type-to-select, the form value and the accessibility tree are
 * the browser's in every engine.
 */
import type { HTMLAttributes } from 'astro/types';

interface Option {
  value: string;
  label: string;
  disabled?: boolean;
  /** Options with the same group go into one <optgroup>. */
  group?: string;
}

interface Props extends Omit<HTMLAttributes<'select'>, 'size'> {
  options: Option[];
  name?: string;
  /** Selected value. */
  value?: string;
  /** Visible label as <label for>. Without it, pass aria-label. */
  label?: string;
  /** A disabled first option shown until a choice is made. */
  placeholder?: string;
  size?: 'sm' | 'md';
  id?: string;
}

const { options, name, value, label, placeholder, size = 'md', id, class: className, style, ...rest } = Astro.props;
// unique per render, not per name: a PromptInput and an AnimatedSelect both named "model" can share a page
const selectId = id ?? `ma-aselect-${name ?? 'select'}-${Math.random().toString(36).slice(2, 7)}`;
// group order is first appearance; ungrouped options stay where they are
const groups: { name: string | undefined; items: Option[] }[] = [];
for (const o of options) {
  const last = groups[groups.length - 1];
  if (last && last.name === o.group) last.items.push(o);
  else groups.push({ name: o.group, items: [o] });
}
---

{label && <label class="ma-aselect__label" for={selectId}>{label}</label>}
<span class:list={['ma-aselect', className]} data-size={size} style={style}>
  <select id={selectId} class="ma-aselect__select" name={name} {...rest}>
    {placeholder && <option value="" disabled selected={value === undefined}>{placeholder}</option>}
    {groups.map((g) =>
      g.name ? (
        <optgroup label={g.name}>{g.items.map((o) => <option value={o.value} disabled={o.disabled} selected={o.value === value}>{o.label}</option>)}</optgroup>
      ) : (
        g.items.map((o) => <option value={o.value} disabled={o.disabled} selected={o.value === value}>{o.label}</option>)
      ),
    )}
  </select>
  <svg class="ma-aselect__chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
</span>

<style is:global>
  @layer components {
    :where(.ma-aselect) {
      --ma-aselect-pad: 0.6em 2.2em 0.6em 0.9em;
      position: relative;
      display: inline-grid;
      vertical-align: middle;
      color: inherit;
    }
    :where(.ma-aselect[data-size='sm']) {
      --ma-aselect-pad: 0.35em 1.9em 0.35em 0.65em;
      font-size: 0.875em;
    }
    :where(.ma-aselect__label) {
      display: block;
      margin-block-end: 0.5em;
      font-size: 0.875em;
    }
    /* every engine: the native select in the tokens; the chevron is ours so the two looks match */
    :where(.ma-aselect__select) {
      appearance: none;
      -webkit-appearance: none;
      grid-area: 1 / 1;
      min-inline-size: 8em;
      margin: 0;
      padding: var(--ma-aselect-pad);
      border: 1px solid var(--ma-edge);
      border-radius: 0.6em;
      background: var(--ma-panel);
      color: inherit;
      font: inherit;
      line-height: 1.2;
      cursor: pointer;
      transition: border-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-aselect__select:hover) {
      border-color: color-mix(in srgb, var(--ma-ink) 35%, transparent);
    }
    :where(.ma-aselect__select:focus-visible) {
      outline: 2px solid var(--ma-ink);
      outline-offset: 2px;
    }
    :where(.ma-aselect__select:disabled) {
      cursor: not-allowed;
      opacity: 0.5;
      transition: none;
    }
    :where(.ma-aselect__select:user-invalid) {
      border-color: #e0563f;
    }
    :where(.ma-aselect__chevron) {
      grid-area: 1 / 1;
      justify-self: end;
      align-self: center;
      inline-size: 1em;
      block-size: 1em;
      margin-inline-end: 0.7em;
      opacity: 0.6;
      pointer-events: none;
      transition: rotate var(--ma-duration) var(--ma-ease);
    }
    /* the customizable select: the picker in the tokens, options staggered in, the picker leaving on allow-discrete */
    @supports (appearance: base-select) {
      :where(.ma-aselect__select),
      :where(.ma-aselect__select)::picker(select) {
        appearance: base-select;
      }
      :where(.ma-aselect__select)::picker-icon {
        display: none;
      }
      :where(.ma-aselect:has(.ma-aselect__select:open) .ma-aselect__chevron) {
        rotate: 180deg;
      }
      :where(.ma-aselect__select)::picker(select) {
        margin-block-start: 0.35em;
        padding: 0.35em;
        border: 1px solid var(--ma-edge);
        border-radius: 0.75em;
        background: var(--ma-panel);
        color: inherit;
        box-shadow: 0 12px 32px -16px rgb(0 0 0 / 0.35);
        opacity: 0;
        translate: 0 calc(var(--ma-travel-hover) * -1);
        transition:
          opacity var(--ma-duration-fast) var(--ma-ease-out),
          translate var(--ma-duration-fast) var(--ma-ease-out),
          display var(--ma-duration-fast) allow-discrete,
          overlay var(--ma-duration-fast) allow-discrete;
      }
      :where(.ma-aselect__select:open)::picker(select) {
        opacity: 1;
        translate: 0 0;
        transition-duration: var(--ma-duration);
        transition-timing-function: var(--ma-ease);
      }
      @starting-style {
        :where(.ma-aselect__select:open)::picker(select) {
          opacity: 0;
          translate: 0 calc(var(--ma-travel-hover) * -1);
        }
      }
      :where(.ma-aselect__select option) {
        display: flex;
        align-items: center;
        gap: 0.5em;
        padding: 0.45em 0.7em;
        border-radius: 0.45em;
        opacity: 1;
        translate: 0 0;
        transition:
          opacity var(--ma-duration) var(--ma-ease-out),
          translate var(--ma-duration) var(--ma-ease),
          background-color var(--ma-duration-fast) var(--ma-ease-out);
        /* one option after another: the delay is its index in the group, on the stagger token */
        transition-delay: calc(sibling-index() * var(--ma-stagger));
      }
      @starting-style {
        :where(.ma-aselect__select:open option) {
          opacity: 0;
          translate: 0 calc(var(--ma-travel-hover) * -1.5);
        }
      }
      :where(.ma-aselect__select option:hover),
      :where(.ma-aselect__select option:focus-visible) {
        background: color-mix(in srgb, var(--ma-ink) 8%, transparent);
        transition-delay: 0s;
      }
      :where(.ma-aselect__select option:checked) {
        font-weight: 500;
      }
      :where(.ma-aselect__select option:disabled) {
        opacity: 0.45;
      }
      :where(.ma-aselect__select option)::checkmark {
        order: 1;
        margin-inline-start: auto;
        font-size: 0.85em;
        opacity: 0.7;
      }
      :where(.ma-aselect__select optgroup) {
        padding-block: 0.25em;
      }
      :where(.ma-aselect__select optgroup)::label {
        padding: 0.35em 0.7em 0.2em;
        font-size: 0.75em;
        letter-spacing: 0.06em;
        text-transform: uppercase;
        opacity: 0.55;
      }
    }
    @media (forced-colors: active) {
      :where(.ma-aselect__select) {
        border-color: ButtonText;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-aselect__select)::picker(select),
      :where(.ma-aselect__select option),
      :where(.ma-aselect__chevron) {
        transition: none;
        translate: 0 0;
      }
    }
  }
</style>

```
