Select

Displays a list of options for the user to pick from, triggered by a button.

Installation

Install the dependencies

pnpm add cn github:sveltery/base

Copy the component

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

src/lib/components/ui/select/SelectContent.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Select as Primitive } from '@sveltery/base/select';
  import { cn } from 'cn';
  import SelectScrollDownButton from './SelectScrollDownButton.svelte';
  import SelectScrollUpButton from './SelectScrollUpButton.svelte';
  let {
    children,
    class: classProp,
    side = 'bottom',
    sideOffset = 4,
    align = 'center',
    alignOffset = 0,
    alignItemWithTrigger = true,
    ...props
  }: ComponentProps<typeof Primitive.Popup> & Pick<ComponentProps<typeof Primitive.Positioner>, 'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'> = $props();
</script>
<Primitive.Portal>
  <Primitive.Positioner {side} {sideOffset} {align} {alignOffset} {alignItemWithTrigger} class="isolate z-50">
    <Primitive.Popup
      data-slot="select-content"
      data-align-trigger={alignItemWithTrigger}
      {...props}
      class={cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none', classProp)}
    >
      <SelectScrollUpButton />
      <Primitive.List>{@render children?.()}</Primitive.List>
      <SelectScrollDownButton />
    </Primitive.Popup>
  </Primitive.Positioner>
</Primitive.Portal>
src/lib/components/ui/select/SelectGroup.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Select as Primitive } from '@sveltery/base/select';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Group> = $props();
</script>
<Primitive.Group data-slot="select-group" {children} {...props} class={cn('scroll-my-1 p-1', classProp)} />
src/lib/components/ui/select/SelectItem.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Select as Primitive } from '@sveltery/base/select';
  import { cn } from 'cn';
  import CheckIcon from '@lucide/svelte/icons/check';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Item> = $props();
</script>
<Primitive.Item
  data-slot="select-item"
  {...props}
  class={cn("focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", classProp)}
>
  <Primitive.ItemText class="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
    {@render children?.()}
  </Primitive.ItemText>
  <Primitive.ItemIndicator class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
    <CheckIcon class="pointer-events-none" />
  </Primitive.ItemIndicator>
</Primitive.Item>
src/lib/components/ui/select/SelectLabel.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Select as Primitive } from '@sveltery/base/select';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.GroupLabel> = $props();
</script>
<Primitive.GroupLabel data-slot="select-label" {children} {...props} class={cn('text-muted-foreground px-1.5 py-1 text-xs', classProp)} />
src/lib/components/ui/select/SelectScrollDownButton.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Select as Primitive } from '@sveltery/base/select';
  import { cn } from 'cn';
  import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
  let { class: classProp, ...props }: ComponentProps<typeof Primitive.ScrollDownArrow> = $props();
</script>
<Primitive.ScrollDownArrow data-slot="select-scroll-down-button" {...props} class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full", classProp)}>
  <ChevronDownIcon />
</Primitive.ScrollDownArrow>
src/lib/components/ui/select/SelectScrollUpButton.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Select as Primitive } from '@sveltery/base/select';
  import { cn } from 'cn';
  import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
  let { class: classProp, ...props }: ComponentProps<typeof Primitive.ScrollUpArrow> = $props();
</script>
<Primitive.ScrollUpArrow data-slot="select-scroll-up-button" {...props} class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full", classProp)}>
  <ChevronUpIcon />
</Primitive.ScrollUpArrow>
src/lib/components/ui/select/SelectSeparator.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Select as Primitive } from '@sveltery/base/select';
  import { cn } from 'cn';
  let { class: classProp, ...props }: ComponentProps<typeof Primitive.Separator> = $props();
</script>
<Primitive.Separator data-slot="select-separator" {...props} class={cn('bg-border -mx-1 my-1 h-px pointer-events-none', classProp)} />
src/lib/components/ui/select/SelectTrigger.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Select as Primitive } from '@sveltery/base/select';
  import { cn } from 'cn';
  import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
  let { children, class: classProp, size = 'default', ...props }: ComponentProps<typeof Primitive.Trigger> & { size?: 'sm' | 'default' } = $props();
</script>
<Primitive.Trigger
  data-slot="select-trigger"
  data-size={size}
  {...props}
  class={cn("border-input data-placeholder:text-muted-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 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0", classProp)}
>
  {@render children?.()}
  <Primitive.Icon class="text-muted-foreground size-4 pointer-events-none">
    {#snippet render(iconProps)}
      <ChevronDownIcon {...(iconProps as Record<string, unknown>)} />
    {/snippet}
  </Primitive.Icon>
</Primitive.Trigger>
src/lib/components/ui/select/SelectValue.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Select as Primitive } from '@sveltery/base/select';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Value> = $props();
</script>
<Primitive.Value data-slot="select-value" {children} {...props} class={cn('flex flex-1 text-left', classProp)} />
src/lib/components/ui/select/index.ts
import { Select as Primitive } from '@sveltery/base/select';

export const Select = Primitive.Root as typeof Primitive.Root;
export { default as SelectGroup } from './SelectGroup.svelte';
export { default as SelectValue } from './SelectValue.svelte';
export { default as SelectTrigger } from './SelectTrigger.svelte';
export { default as SelectContent } from './SelectContent.svelte';
export { default as SelectLabel } from './SelectLabel.svelte';
export { default as SelectItem } from './SelectItem.svelte';
export { default as SelectSeparator } from './SelectSeparator.svelte';
export { default as SelectScrollUpButton } from './SelectScrollUpButton.svelte';
export { default as SelectScrollDownButton } from './SelectScrollDownButton.svelte';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '$lib/components/ui/select';

  const items = [
    { label: 'Light', value: 'light' },
    { label: 'Dark', value: 'dark' },
    { label: 'System', value: 'system' }
  ];
</script>
<Select {items}>
  <SelectTrigger class="w-[180px]">
    <SelectValue placeholder="Theme" />
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      {#each items as item (item.value)}
        <SelectItem value={item.value}>{item.label}</SelectItem>
      {/each}
    </SelectGroup>
  </SelectContent>
</Select>

Pass items to Select so SelectValue shows the label of the selected item instead of its value. An item with a null value acts as the placeholder: its label shows while nothing is selected.

Composition

Use the following composition to build a Select.

Select
├── SelectTrigger
│   └── SelectValue
└── SelectContent
    ├── SelectGroup
    │   ├── SelectLabel
    │   ├── SelectItem
    │   └── SelectItem
    ├── SelectSeparator
    └── SelectGroup
        ├── SelectLabel
        ├── SelectItem
        └── SelectItem

Binding the value

Bind value to read or change the selection from outside, or pass defaultValue to set the starting value. Bind open to control the popup.

<script lang="ts">
  let theme = $state<string | null>('system');
</script>

<Select {items} bind:value={theme}>
  ...
</Select>

Align Item With Trigger

Set alignItemWithTrigger on SelectContent to choose how the popup sits against the trigger. When true, the default, the popup lines the selected item up with the trigger. When false, the popup aligns to the trigger edge like a menu, and side and align apply.

Toggle to align the item with the trigger.

Groups

Use SelectGroup, SelectLabel and SelectSeparator to organize items.

Scrollable

A select with many items scrolls inside the popup. Scroll arrows appear at the edges while there is more to see.

Multiple selection

Set multiple to let the user pick several items. The value becomes an array, and the popup stays open while the user picks. Pass a function to SelectValue to format the selection.

<Select {items} multiple defaultValue={[]}>
  <SelectTrigger class="w-72">
    <SelectValue>
      {#snippet children(value)}
        {(value as string[]).length === 0 ? 'Select fruits' : `${(value as string[]).length} fruits selected`}
      {/snippet}
    </SelectValue>
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      {#each items as item (item.value)}
        <SelectItem value={item.value}>{item.label}</SelectItem>
      {/each}
    </SelectGroup>
  </SelectContent>
</Select>

Object values

An item value can be an object. Pass itemToStringValue so the form value and the hidden input get a string, and itemToStringLabel when the object has no label.

<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
  <SelectTrigger class="h-auto! w-72">
    <SelectValue>
      {#snippet children(value)}
        {(value as Plan).name}
      {/snippet}
    </SelectValue>
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      {#each plans as plan (plan.name)}
        <SelectItem value={plan}>{plan.name}</SelectItem>
      {/each}
    </SelectGroup>
  </SelectContent>
</Select>

Forms

Give Select a name and it submits with its form through a hidden input, and a form reset restores defaultValue. Set required to block submission until the user picks a value.

<form method="POST">
  <Select name="fruit" {items} required>
    ...
  </Select>
  <button type="submit">Submit</button>
</form>

Disabled

Set disabled on Select to disable the whole control, or on a SelectItem to disable one option.

Invalid

Set aria-invalid on SelectTrigger to show an error state. In a Field, also set data-invalid on the Field and add a FieldError.

<Field data-invalid>
  <FieldLabel>Fruit</FieldLabel>
  <Select {items}>
    <SelectTrigger aria-invalid>
      <SelectValue />
    </SelectTrigger>
    ...
  </Select>
  <FieldError>Please select a fruit.</FieldError>
</Field>

RTL

Wrap the select in a DirectionProvider and set dir on SelectTrigger and SelectContent. Use the buttons to switch the language.

Examples

Basic
Sides
With Icons
With Groups & Labels
Large List
Multiple Selection
Sizes
Subscription Plan
With Button
Item Aligned
With Field

Choose your favorite fruit from the list.

Invalid
Inline with Input & NativeSelect
Disabled
In Dialog

API reference

Select is styled on top of the Sveltery Base Select primitive, which owns the popup positioning, keyboard navigation, typeahead, focus handling, dismissal and form value. SelectContent renders the Base portal, positioner, popup and list with scroll arrows, and takes the positioner's side, align, sideOffset, alignOffset and alignItemWithTrigger props. SelectTrigger takes size (default or sm). The trigger sets data-popup-open while the popup is open, so style the open state with data-popup-open:. See the Base UI Select documentation for the behavior it ports.