Native Select

A styled native HTML select element with consistent design system integration.

Basic
With Groups
Sizes
With Field

Select your country of residence.

Disabled
Invalid

Installation

Install the dependencies

pnpm add cn

Copy the component

Copy the following files into src/lib/components/ui/native-select.

src/lib/components/ui/native-select/NativeSelect.svelte
<script lang="ts">
  import { Input as Primitive } from '@sveltery/base/input';
  import type { InputProps } from '@sveltery/base/input';
  import type { HTMLSelectAttributes } from 'svelte/elements';
  import type { NativeSelectProps } from './types.js';
  import { cn } from 'cn';
  import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
  let { children, class: classProp, size = 'default', value = $bindable(), ...props }: NativeSelectProps = $props();
</script>
<div class={cn('group/native-select relative w-fit has-[select:disabled]:opacity-50', classProp)} data-slot="native-select-wrapper" data-size={size}>
  {#if props.multiple}
    <!-- Base's control holds one value, so a multiple select stays native. -->
    <select data-slot="native-select" data-size={size} class="border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-8 w-full min-w-0 appearance-none rounded-lg border bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 outline-none disabled:pointer-events-none disabled:cursor-not-allowed" {...props} bind:value>
      {@render children?.()}
    </select>
  {:else}
    <!-- Base labels, describes and validates its control, so the select renders through it. -->
    <Primitive {...props as InputProps} bind:value>
      {#snippet render(control)}
        <select data-slot="native-select" data-size={size} class="border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-8 w-full min-w-0 appearance-none rounded-lg border bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 outline-none disabled:pointer-events-none disabled:cursor-not-allowed" {...control as HTMLSelectAttributes}>
          {@render children?.()}
        </select>
      {/snippet}
    </Primitive>
  {/if}
  <ChevronDownIcon
    class="text-muted-foreground top-1/2 right-2.5 size-4 -translate-y-1/2 pointer-events-none absolute select-none"
    aria-hidden="true"
    data-slot="native-select-icon"
  />
</div>
src/lib/components/ui/native-select/NativeSelectOptGroup.svelte
<script lang="ts">
  import type { NativeSelectOptGroupProps } from './types.js';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: NativeSelectOptGroupProps = $props();
</script>
<optgroup data-slot="native-select-optgroup" {...props} class={cn('bg-[Canvas] text-[CanvasText]', classProp)}>
  {@render children?.()}
</optgroup>
src/lib/components/ui/native-select/NativeSelectOption.svelte
<script lang="ts">
  import type { NativeSelectOptionProps } from './types.js';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: NativeSelectOptionProps = $props();
</script>
<option data-slot="native-select-option" {...props} class={cn('bg-[Canvas] text-[CanvasText]', classProp)}>
  {@render children?.()}
</option>
src/lib/components/ui/native-select/types.ts
import type { HTMLOptgroupAttributes, HTMLOptionAttributes, HTMLSelectAttributes } from 'svelte/elements';

export type NativeSelectProps = Omit<HTMLSelectAttributes, 'size'> & { size?: 'sm' | 'default' };
export type NativeSelectOptionProps = HTMLOptionAttributes;
export type NativeSelectOptGroupProps = HTMLOptgroupAttributes;
src/lib/components/ui/native-select/index.ts
export { default as NativeSelect } from './NativeSelect.svelte';
export { default as NativeSelectOption } from './NativeSelectOption.svelte';
export { default as NativeSelectOptGroup } from './NativeSelectOptGroup.svelte';
export type { NativeSelectProps, NativeSelectOptionProps, NativeSelectOptGroupProps } from './types.js';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { NativeSelect, NativeSelectOption } from '$lib/components/ui/native-select';
  let status = $state('todo');
</script>
<NativeSelect name="status" bind:value={status}>
  <NativeSelectOption value="todo">Todo</NativeSelectOption>
  <NativeSelectOption value="in-progress">In Progress</NativeSelectOption>
  <NativeSelectOption value="done">Done</NativeSelectOption>
</NativeSelect>

Forms

NativeSelect is a real <select>, so it submits with its form and resets like one. A form reset selects the option marked selected in the markup, or the first option when none is.

<form method="POST">
  <NativeSelect name="priority">
    <NativeSelectOption value="low">Low</NativeSelectOption>
    <NativeSelectOption value="medium" selected>Medium</NativeSelectOption>
    <NativeSelectOption value="high">High</NativeSelectOption>
  </NativeSelect>
  <button type="reset">Reset</button>
</form>