Native select
A styled native select with a visible label, Required or Optional marker, hint, placeholder, option groups and a consumer-set error. The browser's own picker opens it, and it submits before any script runs.
cmp_native_select_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_native_select_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-32497216df9f515e43b4c039292dd246ca52cb5340f39d11466a264fa53217b5
<!--
A native <select> drawn as the same shape as a text field: a visible label with an optional
Required or Optional marker, an optional hint, the select with a chevron in a reserved end
gutter, and a consumer-supplied error. The browser still owns the picker, so it opens as the
platform list or sheet, searches by typing and submits with its form before any script runs.
-->
<script lang="ts" module>
export type NativeSelectSize = 'sm' | 'md' | 'lg';
export type NativeSelectRequiredIndicator = 'required' | 'optional' | 'none';
/** One choice. The label is what the closed select and the picker show. */
export interface NativeSelectOption {
value: string;
label: string;
disabled?: boolean;
}
/** A labelled group of choices, rendered as an <optgroup>. */
export interface NativeSelectGroup {
group: string;
options: NativeSelectOption[];
disabled?: boolean;
}
export type NativeSelectItem = NativeSelectOption | NativeSelectGroup;
</script>
<script lang="ts">
import { untrack } from 'svelte';
import type { HTMLSelectAttributes } from 'svelte/elements';
type Passthrough = Omit<
HTMLSelectAttributes,
| 'value'
| 'id'
| 'name'
| 'size'
| 'required'
| 'disabled'
| 'multiple'
| 'class'
| 'children'
| 'aria-invalid'
| 'dir'
| 'defaultValue'
>;
interface Props extends Passthrough {
/** Visible label. Always shown; the placeholder never stands in for it. */
label: string;
/** Choices and groups of choices, in display order. */
options: NativeSelectItem[];
/** Bindable value: the chosen option's value, or '' while the placeholder shows. */
value?: string;
/** Form field name for native submission. */
name?: string;
/** First option, shown until a choice is made and never submitted, e.g. 'Choose a country'. */
placeholder?: string;
/** Native required attribute. Shows the Required marker unless requiredIndicator says otherwise. */
required?: boolean;
/** Help text under the label, linked with aria-describedby. */
hint?: string;
/** Validation message from the consumer. Sets aria-invalid; the component never validates. */
error?: string | null;
/** sm 32 px, md 36 px, lg 40 px tall, matching action buttons; 44 px on touch screens. */
size?: NativeSelectSize;
/** Native disabled attribute: out of the tab order and not submitted. */
disabled?: boolean;
/** Which marker follows the label. Defaults to 'required' for required fields, otherwise 'none'. */
requiredIndicator?: NativeSelectRequiredIndicator;
/** Marker text for required fields, for other languages. */
requiredLabel?: string;
/** Marker text for optional fields, for other languages. */
optionalLabel?: string;
/** Read before the error by screen readers only, for other languages. */
errorPrefix?: string;
/** Hold one line (or this many lines) under the field for the error, so setting it moves nothing below. */
reserveError?: boolean | number;
/** Select id. Defaults to one generated per instance. */
id?: string;
/** Text direction for the whole field, so the label, value and chevron all follow it. */
dir?: HTMLSelectAttributes['dir'];
/** Extra classes on the outer wrapper, for placement. */
class?: string;
}
let {
label,
options,
value = $bindable(),
name,
placeholder,
required = false,
hint,
error = null,
size = 'md',
disabled = false,
requiredIndicator,
requiredLabel = 'Required',
optionalLabel = 'Optional',
errorPrefix = 'Error:',
reserveError = false,
id,
dir,
class: className,
...rest
}: Props = $props();
const uid = $props.id();
const selectId = $derived(id ?? `${uid}-select`);
/* Hint and error ids come from the instance, so a supplied select id can never collide with them. */
const hintId = `${uid}-hint`;
const errorId = `${uid}-error`;
const invalid = $derived(!!error);
/* Lines held for the error, so a message that fits them moves nothing below the field. */
const reserveLines = $derived(reserveError === true ? 1 : Math.max(0, Math.floor(+reserveError)));
const marker = $derived(requiredIndicator ?? (required ? 'required' : 'none'));
/* Hint first, then the error, then anything the consumer attached. */
const describedBy = $derived(
[
...new Set(
[
hint && hintId,
invalid && errorId,
...(rest['aria-describedby'] ?? '').split(/\s+/)
].filter(Boolean)
)
].join(' ') || undefined
);
/*
* The value the field starts with marks its option as the default, so the choice renders before
* hydration and a form reset returns to it (the placeholder, when nothing was chosen).
*/
const defaultValue = untrack(() => (value === undefined && placeholder ? '' : value));
const isGroup = (item: NativeSelectItem): item is NativeSelectGroup => 'group' in item;
</script>
<div
class={['native-select grid min-w-0 gap-2', className]}
{dir}
data-size={size}
data-invalid={invalid ? '' : undefined}
>
<div class="min-w-0">
<label
for={selectId}
class={[
'block leading-5 font-medium text-pretty break-words text-(--_ink)',
size === 'sm' ? 'text-[13px]' : 'text-sm'
]}
>
{label}{#if marker === 'required'}
<!-- The native required attribute already says so to assistive technology. The en
space is a break point, so a marker that wraps starts on the label's edge. -->
<span class="text-[13px] font-normal text-(--_muted)" aria-hidden="true"
> {requiredLabel}</span
>{:else if marker === 'optional'}
<span class="text-[13px] font-normal text-(--_muted)"> {optionalLabel}</span>{/if}
</label>
{#if hint}
<p id={hintId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
{hint}
</p>
{/if}
</div>
<div class="min-w-0">
<div class="relative">
<select
{...rest}
bind:value
{defaultValue}
{name}
{required}
{disabled}
id={selectId}
aria-describedby={describedBy}
aria-invalid={invalid ? 'true' : undefined}
class={[
'native-select__input peer block h-(--_h) w-full min-w-0 appearance-none truncate border ps-3 pe-9',
'border-(--_edge) bg-(--_surface) text-(--_ink) outline-none pointer-coarse:text-base',
'transition-[color,border-color,box-shadow,background-color] duration-150 ease-(--_ease) motion-reduce:transition-none',
/* Hover only where a press opens the picker: one step darker edge. */
'enabled:hover:border-(--_edge-hover)',
/* Focus: a two-pixel edge (one border plus one ring) with a soft halo outside. */
'focus-visible:border-(--_ring) focus-visible:shadow-(--_focus-ring)',
/* Disabled: a raised fill, a quieter edge and the value in the muted tone, rather than
fading the whole control, so it never reads as a placeholder. */
'disabled:cursor-not-allowed disabled:border-(--_edge-disabled) disabled:bg-(--_fill-raised) disabled:text-(--_muted)',
/* Autofill (autocomplete="country"): tint the fill from the accent. */
'autofill:bg-(--_fill-autofill)',
/* Forced colours drop shadows, so focus becomes a system-colour outline. */
'forced-colors:focus-visible:outline-2 forced-colors:focus-visible:outline-offset-2 forced-colors:focus-visible:outline-[Highlight]',
size === 'sm' && 'rounded-md text-[13px] leading-5',
size === 'md' && 'rounded-lg text-sm leading-5',
size === 'lg' && 'rounded-lg text-base leading-6'
]}
>
{#if placeholder}
<!-- Disabled so a required select cannot submit it, hidden so the picker lists only
real choices. As the first option with an empty value, it is what makes an
untouched required select fail native validation. -->
<option class="native-select__placeholder" value="" disabled hidden>{placeholder}</option>
{/if}
{#each options as item (item)}
{#if isGroup(item)}
<optgroup label={item.group} disabled={item.disabled}>
{#each item.options as option (option)}
<option value={option.value} disabled={option.disabled}>{option.label}</option>
{/each}
</optgroup>
{:else}
<option value={item.value} disabled={item.disabled}>{item.label}</option>
{/if}
{/each}
</select>
<!-- The chevron sits in the select's reserved end gutter and lets every press through to it.
It is at the inline end, so it moves to the left in right-to-left text. -->
<span
class="native-select__chevron pointer-events-none absolute inset-y-0 end-3 grid place-items-center text-(--_muted) peer-disabled:text-(--_chevron-disabled) forced-colors:text-[CanvasText]"
aria-hidden="true"
>
<svg class="size-4" viewBox="0 0 16 16" fill="none">
<path
d="M4.5 6.25 8 9.75l3.5-3.5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</div>
{#if invalid}
<p
id={errorId}
class="mt-1 flex min-h-(--_reserve) items-start gap-1 text-[13px] leading-5 text-(--_danger)"
style:--_reserve={reserveLines ? `${reserveLines * 1.25}rem` : undefined}
>
<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
<path
d="M8 4.75v3.5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
<circle cx="8" cy="11" r="1" fill="currentColor" />
</svg>
</span>
<!-- No text-pretty: on a one-line message it pulls words down to balance a second line. -->
<span class="min-w-0 break-words"
><span class="sr-only">{`${errorPrefix} `}</span>{error}</span
>
</p>
{:else if reserveLines}
<div
class="mt-1 h-(--_reserve)"
style:--_reserve={`${reserveLines * 1.25}rem`}
aria-hidden="true"
></div>
{/if}
</div>
</div>
<style>
/* Public tokens: set --native-select-* on the field or any ancestor to retone it. */
.native-select {
--_accent: var(--native-select-accent, #18181b);
--_danger: var(--native-select-danger, #b91c1c);
--_ink: var(--native-select-ink, #18181b);
--_muted: var(--native-select-muted, #52525b);
--_placeholder: var(--native-select-placeholder, #71717a);
/* 3:1 against white: the edge is the only thing marking the control (WCAG 1.4.11). */
--_hairline: var(--native-select-hairline, rgb(0 0 0 / 0.44));
--_surface: var(--native-select-surface, #ffffff);
/* The browser paints the open picker; on a dark retone set this to dark so it follows. */
--_scheme: var(--native-select-scheme, light);
/* Formulas the state utilities read, mixed from the tokens so they follow any retone. */
--_h: 2.25rem;
--_ease: cubic-bezier(0.2, 0, 0, 1);
--_edge: var(--_hairline);
--_edge-hover: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
--_edge-disabled: color-mix(in oklab, var(--_hairline) 55%, transparent);
--_ring: var(--_accent);
--_focus-ring:
0 0 0 1px var(--_ring), 0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
--_fill-raised: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
--_fill-autofill: color-mix(in oklab, var(--_surface), var(--_accent) 6%);
--_chevron-disabled: color-mix(in oklab, var(--_muted) 60%, transparent);
}
.native-select[data-size='sm'] {
--_h: 2rem;
}
.native-select[data-size='lg'] {
--_h: 2.5rem;
}
@media (pointer: coarse) {
.native-select[data-size] {
--_h: 2.75rem;
}
}
/* Invalid: the edge, its hover and the focus ring all carry the error colour. */
.native-select[data-invalid] {
--_edge: var(--_danger);
--_edge-hover: var(--_danger);
--_ring: var(--_danger);
}
/* The browser's open picker follows the surface; there is no utility for color-scheme. */
.native-select__input {
color-scheme: var(--_scheme);
}
/* Blink and WebKit paint their own autofill text colour; there is no utility for it. */
.native-select__input:autofill {
-webkit-text-fill-color: var(--_ink);
}
/*
* A relation: while the placeholder is the chosen option, the closed select reads in the
* placeholder tone, and turns to ink once a choice is made. A disabled select keeps its muted
* value instead.
*/
.native-select__input:enabled:has(> .native-select__placeholder:checked) {
color: var(--_placeholder);
}
/*
* Options the select renders, for the pickers that honour option styles (Windows, Linux): ink
* on the surface, and unavailable choices, including those in a disabled group, in the
* placeholder tone so they still read as unavailable.
*/
.native-select__input :global(option),
.native-select__input :global(optgroup) {
color: var(--_ink);
background-color: var(--_surface);
}
.native-select__input :global(option:disabled) {
color: var(--_placeholder);
}
</style>
Usage#
On this pageGive it a label, a name and an options array (plain options or { group, options } groups) and it renders a labelled native select that submits with its form before any script runs. Bind value, add a placeholder for a choice nobody has made yet, and set error when your own validation fails. It does not search, select several values, validate, or style the browser's open list.
- Suggested location
src/lib/components/native-select-01
Limitations
- The browser draws the open list. macOS, iOS and Android ignore option styles; Windows and Linux pickers take the ink, surface and a quieter tone for disabled options, and nothing else.
- One value only: no multi-select, search box, chips or rich option content. Those are the custom select, combobox and multi-select patterns.
- The placeholder is hidden from the open list and cannot be chosen again. For an optional field that someone may want to clear, add a real option such as
{ value: 'none', label: 'No preference' }. - The component never validates. The native required attribute still triggers the browser's own message on submit unless the form sets novalidate.
- Long option labels are cut with an ellipsis in the closed control; the open list shows them in full.
- The placeholder tone uses :has(); browsers without it show the placeholder in ink, which is still readable.
- Values must be unique across the whole list, groups included. Changing options never changes value on its own; if the chosen option disappears, set value yourself.
- Needs Svelte 5.57 or later, which added
defaultValueon select; that is what lets a form reset return to the starting choice or the placeholder.
Example
<script lang="ts">
import NativeSelect from '$lib/components/native-select-01/NativeSelect.svelte';
const countries = [
{ value: 'AU', label: 'Australia' },
{ value: 'CA', label: 'Canada' },
{ value: 'DE', label: 'Germany' },
{ value: 'GB', label: 'United Kingdom' },
{ value: 'US', label: 'United States' }
];
let country = $state('');
let error = $state<string | null>(null);
function check(event: SubmitEvent) {
error = country ? null : 'Choose your billing country.';
if (error) event.preventDefault();
}
</script>
<form method="POST" class="max-w-sm" novalidate onsubmit={check}>
<NativeSelect
label="Country"
name="country"
autocomplete="country"
required
placeholder="Choose a country"
hint="Sets the tax on each invoice."
options={countries}
bind:value={country}
{error}
/>
</form>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Native select#
A labelled <select> that keeps the browser's own picker. On a phone it opens the platform
sheet or wheel, on a desktop the system list with type-ahead, and it submits with its form
before any script runs. Only the closed control is styled: the same edge, heights and radius
as text-field-01, with the chevron in a reserved gutter at the inline end.
<NativeSelect
label="Country"
name="country"
autocomplete="country"
required
placeholder="Choose a country"
options={countries}
bind:value={country}
{error}
/>Options and groups#
options takes plain options and groups in display order. A group renders as an
<optgroup>; a disabled option or group stays visible in the list but cannot be chosen.
const zones = [
{ value: 'UTC', label: 'UTC' },
{
group: 'Europe',
options: [
{ value: 'Europe/London', label: 'London (UTC+00:00)' },
{ value: 'Europe/Berlin', label: 'Berlin (UTC+01:00)' }
]
}
];Values must be unique across the whole list, groups included.
Placeholder#
placeholder adds a first option with an empty value that is disabled and hidden. The closed
select shows it in the placeholder tone until a choice is made, and value is '' meanwhile.
With required, a select left on the placeholder fails the browser's validation, so the form
will not submit. The placeholder cannot be chosen again once something else is, so for an
optional field that someone may want to clear, add a real option such as
{ value: 'none', label: 'No preference' }.
Validation is yours#
The field never validates. Set error when your check or your server rejects the value, and
clear it (null) when it passes. While it is set the field shows the message with an icon,
turns its edge and focus ring to the danger colour, sets aria-invalid and adds the error to
aria-describedby after the hint. The error is not a live region: when a submit fails, move
focus to the first invalid field or to an error summary.
When to reach for something else#
- Fewer than five options: a radio group shows them all without a click.
- A list people search rather than scroll (a few hundred products, people): a combobox.
- Several values, or options with icons and descriptions: a multi-select or custom select.
A country list is the classic exception: long, but well known, and the platform picker's type-ahead finds "Germany" in two keystrokes.
Dark surfaces#
The browser paints the open list. Set --native-select-scheme: dark alongside the dark
retone in the sidecar's customisation guide, or the list opens white on a dark page.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Visible label, always shown. The placeholder never stands in for it. |
options | NativeSelectItem[] | Yes | None | Choices in display order: { value, label, disabled? } options and { group, options, disabled? } groups, rendered as <optgroup>. |
value | string | No | None | Bindable value: the chosen option's value, or '' while the placeholder shows. The value the field starts with is its default: undefined starts on the placeholder (or the browser's first option without one), and a form reset returns to it. Set '' to go back to the placeholder later; a value that matches no option leaves nothing chosen. |
name | string | No | None | Form field name, so native submission includes the field. |
placeholder | string | No | None | Text of a first, disabled and hidden option with an empty value, shown until a choice is made, such as 'Choose a country'. It is never submitted. |
required | boolean | No | false | Native required attribute. With a placeholder, an untouched select blocks native submission. Shows the Required marker unless requiredIndicator says otherwise. |
hint | string | No | None | Help text under the label, linked first in aria-describedby. |
error | string | null | No | null | Validation message from your code. When set, the field shows it with an icon, turns its edge and focus ring to the danger colour and sets aria-invalid. |
size | 'sm' | 'md' | 'lg' | No | 'md' | 32, 36 or 40 px tall with 13, 14 or 16 px text, matching buttons of the same size. Every size is 44 px with 16 px text on touch screens. |
disabled | boolean | No | false | Native disabled attribute: out of the tab order and not submitted. Say why in the hint. |
requiredIndicator | 'required' | 'optional' | 'none' | No | None | Which marker follows the label. Defaults to 'required' for required fields and 'none' otherwise. When most fields are required, mark only the optional ones. |
requiredLabel | string | No | 'Required' | Marker text for required fields. |
optionalLabel | string | No | 'Optional' | Marker text for optional fields. |
errorPrefix | string | No | 'Error:' | Visually hidden text read before the error. |
reserveError | boolean | number | No | false | Hold space under the field for the error: true for one line, a number for that many lines. Setting an error that fits moves nothing below; a longer one still grows the field. |
id | string | No | None | Select id. Defaults to one generated per instance. The hint and error always take generated ids; keep a supplied id unique on the page. |
dir | 'ltr' | 'rtl' | 'auto' | No | None | Text direction for the whole field, so the label, value and chevron agree. Usually inherited from the page instead. |
class | string | No | None | Extra classes on the outer wrapper, for placement. |
Customization#
On this pageChange the copy and markers through props and retone the field through eight --native-select-* CSS variables. Hover, disabled and autofill fills are mixed from those, so they follow any retone.
- Accent:
--native-select-accentcolours the focus edge and its halo, and tints the autofilled fill. Keep it at 3:1 against the page. - Danger:
--native-select-dangercolours the error edge, the error focus ring, the icon and the message. The default isred-700(6.5:1 on white). - Neutrals:
--native-select-inkis the label and value,--native-select-mutedthe hint, marker and chevron,--native-select-placeholderthe placeholder and unavailable options (keep all three at 4.5:1),--native-select-hairlinethe resting edge (3:1, the only thing marking the control) and--native-select-surfacethe fill. - Scheme:
--native-select-schemesets color-scheme on the select, so the browser draws its open list light or dark. Set it to dark whenever the surface is dark. - Worked retone for a
#09090bband:--native-select-accent:#fafafa;--native-select-ink:#fafafa;--native-select-muted:#a1a1aa;--native-select-placeholder:#a1a1aa;--native-select-hairline: rgb(255 255 255 / 0.35);--native-select-surface:#18181b;--native-select-danger:#f87171;--native-select-scheme: dark. - Cream page such as a bakery's
#fbf7f0: leave the hairline alone (it is alpha black, so it tints with the page) and set--native-select-surface:#fffdf9. - Sizes: sm for toolbars and table filters, md for app forms, lg for marketing and checkout forms. Match the size of any button in the same row.
- Options: write labels that make sense alone and put the detail people compare at the end ('Berlin (UTC+01:00)', 'Team, $12 per seat a month'). Use groups when a list passes about fifteen options and has natural headings.
- When not to use it: under five options read better as a radio group, and a list people search more than scroll belongs in a combobox.
- Layout: the field fills its container. Stack fields with a 20 to 24 px gap and pass class for placement. Translate
requiredLabel,optionalLabelanderrorPrefixwith the page.
Public CSS variables
| Variable | Token |
|---|---|
--native-select-accent | accent |
--native-select-danger | danger |
--native-select-ink | ink |
--native-select-muted | muted |
--native-select-placeholder | placeholder |
--native-select-hairline | hairline |
--native-select-surface | surface |
--native-select-scheme | scheme |
Accessibility#
On this page- A native <label for> names the select. Its id comes from
$props.id()or your id prop, so a press on the label focuses the select; the hint and error ids are always generated per instance. aria-describedbylists the hint id, then the error id, then anyaria-describedbyyou pass, each only when present.aria-invalidis set only while error is set.- The placeholder is the first option, with an empty value, disabled and hidden. A required select left on it fails native validation, and it is never submitted.
- The Required marker is visible text beside the label and hidden from assistive technology, because the native required attribute already announces it. The Optional marker is part of the label's name.
- The error is never colour alone: it has an icon, text, and a visually hidden
errorPrefix. It is not a live region; move focus or announce a summary when a submit fails. - The chevron is decorative and
aria-hidden, sits at 7.7:1 against the default fill, and draws in CanvasText under forced colours. Focus draws a two-pixel edge in the accent; in forced-colours mode it becomes a two-pixel Highlight outline. - Keyboard and screen reader behaviour is the platform's: arrow keys, type-ahead and the open list are untouched.
- Selects use 16 px text and grow to 44 px on touch screens, so iOS does not zoom and targets clear 44 px.
Release details#
On this page- Integration
- Local interaction
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.