Menu

Change language

GitHub repo

Select

selectui

Dropdown select primitive — single-select with optional groups, descriptions per item, an accessible hand-rolled listbox (no headless dependency), and zero-JS styled NativeSelect for lightweight/mobile use.

When to use which

Decision Guide

Select is the headless primitive for flat options. Advance Select is the searchable combobox. Cascade and Tree selectors are designed specifically for hierarchical and nested datasets.

ComponentRole
Selectcurrent

Short, flat option lists (< 20 items) where typing to filter is unnecessary.

Primitive Dropdown
AdvanceSelect

Large datasets requiring fuzzy search, multi-select tag pills, or remote option filtering.

Searchable Combobox
CascadeSelect

Sequential multi-level drilldowns (e.g. Country > State > City, Category > Subcategory).

Hierarchical Picker
TreeSelect

Deeply nested taxonomy structures, folder trees, or parent-child permission nodes.

Tree Picker
  • Standard dropdown with static options → Select
  • Searchable or multi-select combobox → Advance Select (view)
  • Sequential multi-level drilldown → Cascade Select (view)
  • Nested tree structure with checkboxes → Tree Select (view)

Installation

$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/select.json
Manifest:https://uipkge.dev/r/svelte/select.jsonInstalls to:src/lib/components/ui/select/

Examples

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
value

Controlled value. Omit + use `defaultValue` for uncontrolled.

string—optional
defaultValuestring—optional
open

Controlled open state. Omit + use `defaultOpen` for uncontrolled.

boolean—optional
defaultOpenbooleanfalseoptional
disabledbooleanfalseoptional
name

Renders a hidden input so the value submits with native forms.

string—optional
requiredbooleanfalseoptional
onValueChange(value: string) => void—optional
onOpenChange(open: boolean) => void—optional
childrenSnippet—optional
refHTMLDivElement | null—optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

SelectItemData
interface SelectItemData {
  value: string
  label: string
  disabled: boolean
}
SelectContext
interface SelectContext {
  readonly open: boolean
  readonly value: string | undefined
  readonly highlighted: string | undefined
  readonly disabled: boolean
  readonly items: SelectItemData[]
  readonly triggerId: string
  readonly contentId: string
  readonly triggerEl: HTMLElement | null
  setOpen: (open: boolean) => void
  selectValue: (value: string) => void
  setHighlighted: (value: string | undefined) => void
  moveHighlight: (direction: 1 | -1) => void
  highlightFirst: () => void
  highlightLast: () => void
  registerItem: (item: SelectItemData) => void
  unregisterItem: (value: string) => void
  registerTrigger: (el: HTMLElement | null) => void
  focusTrigger: () => void
}

Used by

Files installed (15)

  • src/lib/components/ui/select/Select.svelte4.5 kB
    <script lang="ts" module>
      import type { Snippet } from 'svelte'
      import type { HTMLAttributes } from 'svelte/elements'
    
      export interface SelectProps extends HTMLAttributes<HTMLDivElement> {
        /** Controlled value. Omit + use `defaultValue` for uncontrolled. */
        value?: string
        defaultValue?: string
        /** Controlled open state. Omit + use `defaultOpen` for uncontrolled. */
        open?: boolean
        defaultOpen?: boolean
        disabled?: boolean
        /** Renders a hidden input so the value submits with native forms. */
        name?: string
        required?: boolean
        onValueChange?: (value: string) => void
        onOpenChange?: (open: boolean) => void
        children?: Snippet
        ref?: HTMLDivElement | null
      }
    </script>
    
    <script lang="ts">
      import { setContext, untrack } from 'svelte'
      import { cn } from '$lib/utils'
      import { SELECT_CONTEXT_KEY, type SelectContext, type SelectItemData } from './select-context'
    
      let {
        class: className,
        value = $bindable<string | undefined>(),
        defaultValue,
        open = $bindable<boolean | undefined>(),
        defaultOpen = false,
        disabled = false,
        name,
        required = false,
        onValueChange,
        onOpenChange,
        children,
        ref = $bindable(null),
        ...restProps
      }: SelectProps = $props()
    
      let innerValue = $state<string | undefined>(defaultValue)
      let innerOpen = $state(defaultOpen)
      let highlighted = $state<string | undefined>(undefined)
      let items = $state<SelectItemData[]>([])
      let triggerEl = $state<HTMLElement | null>(null)
    
      const currentValue = $derived(value ?? innerValue)
      const currentOpen = $derived(open ?? innerOpen)
    
      const triggerId = `select-trigger-${Math.random().toString(36).slice(2, 9)}`
      const contentId = `select-content-${Math.random().toString(36).slice(2, 9)}`
    
      function setOpen(next: boolean) {
        if (disabled) return
        innerOpen = next
        open = next
        onOpenChange?.(next)
        if (next) {
          const enabled = items.filter((i) => !i.disabled)
          const selected = enabled.find((i) => i.value === currentValue)
          highlighted = (selected ?? enabled[0])?.value
        } else {
          highlighted = undefined
        }
      }
    
      function selectValue(next: string) {
        innerValue = next
        value = next
        onValueChange?.(next)
        setOpen(false)
        focusTrigger()
      }
    
      function moveHighlight(direction: 1 | -1) {
        const enabled = items.filter((i) => !i.disabled)
        if (enabled.length === 0) return
        const idx = enabled.findIndex((i) => i.value === highlighted)
        const next = idx < 0 ? (direction === 1 ? 0 : enabled.length - 1) : (idx + direction + enabled.length) % enabled.length
        highlighted = enabled[next]!.value
      }
    
      function highlightFirst() {
        highlighted = items.find((i) => !i.disabled)?.value
      }
    
      function highlightLast() {
        const enabled = items.filter((i) => !i.disabled)
        highlighted = enabled[enabled.length - 1]?.value
      }
    
      // Called from each SelectItem's $effect. Untracked so reading `items` here
      // doesn't make every item's effect depend on the whole list — otherwise each
      // registration re-ran all items' effects (effect_update_depth_exceeded).
      function registerItem(item: SelectItemData) {
        untrack(() => {
          const idx = items.findIndex((i) => i.value === item.value)
          if (idx >= 0) items[idx] = item
          else items.push(item)
        })
      }
    
      function unregisterItem(itemValue: string) {
        untrack(() => {
          items = items.filter((i) => i.value !== itemValue)
        })
      }
    
      function registerTrigger(el: HTMLElement | null) {
        triggerEl = el
      }
    
      function focusTrigger() {
        triggerEl?.focus()
      }
    
      const ctx: SelectContext = {
        get open() {
          return currentOpen
        },
        get value() {
          return currentValue
        },
        get highlighted() {
          return highlighted
        },
        get disabled() {
          return disabled
        },
        get items() {
          return items
        },
        get triggerId() {
          return triggerId
        },
        get contentId() {
          return contentId
        },
        get triggerEl() {
          return triggerEl
        },
        setOpen,
        selectValue,
        setHighlighted: (v) => (highlighted = v),
        moveHighlight,
        highlightFirst,
        highlightLast,
        registerItem,
        unregisterItem,
        registerTrigger,
        focusTrigger,
      }
      setContext(SELECT_CONTEXT_KEY, ctx)
    </script>
    
    <div bind:this={ref} data-uipkge data-slot="select" data-state={currentOpen ? 'open' : 'closed'} class={cn('relative', className)} {...restProps}>
      {@render children?.()}
      {#if name}
        <input type="hidden" {name} value={currentValue ?? ''} {required} />
      {/if}
    </div>
    
  • src/lib/components/ui/select/SelectContent.svelte7.3 kB
  • src/lib/components/ui/select/SelectGroup.svelte0.4 kB
  • src/lib/components/ui/select/SelectItem.svelte2.7 kB
  • src/lib/components/ui/select/SelectItemText.svelte0.4 kB
  • src/lib/components/ui/select/SelectLabel.svelte0.5 kB
  • src/lib/components/ui/select/SelectScrollDownButton.svelte1.4 kB
  • src/lib/components/ui/select/SelectScrollUpButton.svelte1.4 kB
  • src/lib/components/ui/select/SelectSeparator.svelte0.5 kB
  • src/lib/components/ui/select/SelectTrigger.svelte3 kB
  • src/lib/components/ui/select/SelectValue.svelte0.9 kB
  • src/lib/components/ui/select/NativeSelect.svelte3.4 kB
  • src/lib/components/ui/select/index.ts1.2 kB
  • src/lib/components/ui/select/option-types.ts0.7 kB
  • src/lib/components/ui/select/select-context.ts1.3 kB

Raw manifest:https://uipkge.dev/r/svelte/select.json