Components · Forms
Select
A native select in the field style, with groups, a placeholder and disabled options. No JavaScript.
39% of the 2,062 sites in The UI Index have a form (806 sites).
Both previews render the same markup; the HTML tab below is that markup without a framework.
Install
- React
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ select.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ astro/ select.json
Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS.
import type { SelectHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
import { CHEVRON_PATH, select, type SelectGroup, type SelectOption } from './select.variants';
export type { SelectGroup, SelectOption };
export type SelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> & {
options: (SelectOption | SelectGroup)[];
/** A first, empty option that asks for a choice ("Choose a region"). */
placeholder?: string;
};
const isGroup = (o: SelectOption | SelectGroup): o is SelectGroup => 'options' in o;
/** A native <select> in the field style. Use `defaultValue` (uncontrolled) or `value` with `onChange`. */
export function Select({ options, placeholder, className, defaultValue, value, ...props }: SelectProps) {
const initial = placeholder && value === undefined && defaultValue === undefined ? '' : defaultValue;
return (
<div className={select.root}>
<select className={cn(select.field, className)} value={value} defaultValue={value === undefined ? initial : undefined} {...props}>
{placeholder && <option value="" disabled>{placeholder}</option>}
{options.map((o) =>
isGroup(o) ? (
<optgroup key={o.label} label={o.label}>
{o.options.map((x) => <option key={x.value} value={x.value} disabled={x.disabled}>{x.label}</option>)}
</optgroup>
) : (
<option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>
),
)}
</select>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={select.chevron}><path d={CHEVRON_PATH} /></svg>
</div>
);
}import { fieldClasses } from '../input/input.variants';
/** A native <select> in the field style: the platform's own list, keyboard and screen reader support, no JavaScript.
* The chevron is drawn over the right padding and ignores the pointer. */
export const select = {
root: 'relative w-full',
// While the empty placeholder option is chosen, the field reads as a placeholder (muted), not as a value.
field: `${fieldClasses} h-9 cursor-pointer appearance-none py-1 pr-9 has-[option[value='']:checked]:text-muted-foreground [&_option]:text-foreground`,
chevron: 'pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground',
};
export const CHEVRON_PATH = 'M4 6l4 4 4-4';
export type SelectOption = { value: string; label: string; disabled?: boolean };
export type SelectGroup = { label: string; options: SelectOption[] };---
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/utils';
import { CHEVRON_PATH, select, type SelectGroup, type SelectOption } from './select.variants';
type Props = HTMLAttributes<'select'> & {
options: (SelectOption | SelectGroup)[];
/** The selected value. */
value?: string;
/** A first, empty option that asks for a choice ("Choose a region"). */
placeholder?: string;
};
const { options, value, placeholder, class: className, ...rest } = Astro.props;
const isGroup = (o: SelectOption | SelectGroup): o is SelectGroup => 'options' in o;
---
<div class={select.root}>
<select class={cn(select.field, className)} {...rest}>
{placeholder && <option value="" disabled selected={!value}>{placeholder}</option>}
{
options.map((o) =>
isGroup(o) ? (
<optgroup label={o.label}>
{o.options.map((x) => <option value={x.value} disabled={x.disabled} selected={x.value === value}>{x.label}</option>)}
</optgroup>
) : (
<option value={o.value} disabled={o.disabled} selected={o.value === value}>{o.label}</option>
),
)
}
</select>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class={select.chevron}><path d={CHEVRON_PATH} /></svg>
</div>import { fieldClasses } from '../input/input.variants';
/** A native <select> in the field style: the platform's own list, keyboard and screen reader support, no JavaScript.
* The chevron is drawn over the right padding and ignores the pointer. */
export const select = {
root: 'relative w-full',
// While the empty placeholder option is chosen, the field reads as a placeholder (muted), not as a value.
field: `${fieldClasses} h-9 cursor-pointer appearance-none py-1 pr-9 has-[option[value='']:checked]:text-muted-foreground [&_option]:text-foreground`,
chevron: 'pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground',
};
export const CHEVRON_PATH = 'M4 6l4 4 4-4';
export type SelectOption = { value: string; label: string; disabled?: boolean };
export type SelectGroup = { label: string; options: SelectOption[] };<div class="grid w-full max-w-sm gap-6">
<div class="grid gap-2">
<label class="flex items-center gap-2 text-sm leading-none font-medium has-[input]:items-start has-[input]:leading-snug has-[input]:[&>*:first-child]:mt-px select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50" for="ex-region">
Region
</label>
<div class="relative w-full">
<select class="w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm text-foreground transition-colors placeholder:text-muted-foreground enabled:hover:border-foreground/85 enabled:active:bg-muted aria-invalid:enabled:hover:border-destructive aria-invalid:enabled:hover:bg-[color-mix(in_oklab,var(--destructive)_6%,var(--background))] aria-invalid:enabled:active:bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:outline-destructive h-9 cursor-pointer appearance-none py-1 pr-9 has-[option[value='']:checked]:text-muted-foreground [&_option]:text-foreground" id="ex-region" name="region">
<option value="" disabled selected>Choose a region</option>
<optgroup label="Europe">
<option value="ams">Amsterdam</option>
<option value="fra">Frankfurt</option>
</optgroup>
<optgroup label="Americas">
<option value="iad">Washington</option>
<option value="gru" disabled>São Paulo</option>
</optgroup>
</select>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground"><path d="M4 6l4 4 4-4"></path></svg>
</div>
</div>
<div class="grid gap-2">
<label class="flex items-center gap-2 text-sm leading-none font-medium has-[input]:items-start has-[input]:leading-snug has-[input]:[&>*:first-child]:mt-px select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50" for="ex-plan">
Plan
</label>
<div class="relative w-full">
<select class="w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm text-foreground transition-colors placeholder:text-muted-foreground enabled:hover:border-foreground/85 enabled:active:bg-muted aria-invalid:enabled:hover:border-destructive aria-invalid:enabled:hover:bg-[color-mix(in_oklab,var(--destructive)_6%,var(--background))] aria-invalid:enabled:active:bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:outline-destructive h-9 cursor-pointer appearance-none py-1 pr-9 has-[option[value='']:checked]:text-muted-foreground [&_option]:text-foreground" id="ex-plan" name="plan">
<option value="hobby">Hobby</option>
<option value="pro" selected>Pro</option>
<option value="team">Team</option>
</select>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground"><path d="M4 6l4 4 4-4"></path></svg>
</div>
</div>
<div class="grid gap-2">
<label class="flex items-center gap-2 text-sm leading-none font-medium has-[input]:items-start has-[input]:leading-snug has-[input]:[&>*:first-child]:mt-px select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 opacity-50" for="ex-seats">
Seats
</label>
<div class="relative w-full">
<select class="w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm text-foreground transition-colors placeholder:text-muted-foreground enabled:hover:border-foreground/85 enabled:active:bg-muted aria-invalid:enabled:hover:border-destructive aria-invalid:enabled:hover:bg-[color-mix(in_oklab,var(--destructive)_6%,var(--background))] aria-invalid:enabled:active:bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:outline-destructive h-9 cursor-pointer appearance-none py-1 pr-9 has-[option[value='']:checked]:text-muted-foreground [&_option]:text-foreground" id="ex-seats" name="seats" disabled>
<option value="5">5 seats</option>
</select>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground"><path d="M4 6l4 4 4-4"></path></svg>
</div>
</div>
</div>