Raw bytes of JavaScript, from a production build, measured again for every change that can move them.
Popover
UIA popover that opens beside its trigger, flips when there is no room, closes on outside click or Escape, and animates in and out.
Components / UI
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.
a native select · options stagger in
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
0 B
CSS 7.5 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 138 · not Firefox · Safari 27; needs base-select, picker, sibling-index, starting-style, allow-discrete. 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.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import AnimatedSelect from '@moonarc/core/AnimatedSelect';
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 />Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/animated-select.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* 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>| 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. |
The picker appears in place and the options are all there at once.
ClientRouterA native select; nothing to bind.
Replaces: Select (shadcn / Radix) · AnimatedSelect (Motion Primitives) · Listbox (Headless UI). See the migration table.
Raw bytes of JavaScript, from a production build, measured again for every change that can move them.
A popover that opens beside its trigger, flips when there is no room, closes on outside click or Escape, and animates in and out.
grows with the text · Enter sends · ⇧Enter breaks the line
The AI prompt box: a form with a textarea that grows with its content through field-sizing, a model select, an attachment button that is a real file input, and a send button that dims until something is typed and spins while the form carries data-pending.
a checkbox underneath · Space flips it
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown