Select
selectuiDropdown 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 GuideSelect 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.
| Component | Role | Use when |
|---|---|---|
| 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 |
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/select.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/select.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/select.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/select.jsonhttps://uipkge.dev/r/svelte/select.jsonInstalls to:src/lib/components/ui/select/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
valueControlled value. Omit + use `defaultValue` for uncontrolled. | string | — | optional |
defaultValue | string | — | optional |
openControlled open state. Omit + use `defaultOpen` for uncontrolled. | boolean | — | optional |
defaultOpen | boolean | false | optional |
disabled | boolean | false | optional |
nameRenders a hidden input so the value submits with native forms. | string | — | optional |
required | boolean | false | optional |
onValueChange | (value: string) => void | — | optional |
onOpenChange | (open: boolean) => void | — | optional |
children | Snippet | — | optional |
ref | HTMLDivElement | null | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
SelectItemDatainterface SelectItemData {
value: string
label: string
disabled: boolean
}SelectContextinterface 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
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface SelectContentProps extends HTMLAttributes<HTMLDivElement> { /** Preferred side. Flips automatically when space runs out. */ side?: 'top' | 'bottom' align?: 'start' | 'center' | 'end' sideOffset?: number children?: Snippet ref?: HTMLDivElement | null } </script> <script lang="ts"> import { untrack } from 'svelte' import { cn } from '$lib/utils' import { getSelectContext } from './select-context' import SelectScrollDownButton from './SelectScrollDownButton.svelte' import SelectScrollUpButton from './SelectScrollUpButton.svelte' let { class: className, side = 'bottom', align = 'start', sideOffset = 4, children, ref = $bindable(null), ...restProps }: SelectContentProps = $props() const ctx = getSelectContext('SelectContent') let contentEl: HTMLDivElement | null = $state(null) let viewportEl: HTMLDivElement | null = $state(null) let pos = $state({ top: 0, left: 0, minWidth: 0, maxHeight: 320, placed: false }) let resolvedSide = $state<'top' | 'bottom'>(side) $effect(() => { ref = contentEl }) let mounted = $state(false) $effect(() => { mounted = true }) function place() { const trigger = ctx.triggerEl if (!trigger) return const rect = trigger.getBoundingClientRect() const viewportH = window.innerHeight const viewportW = window.innerWidth const maxHeight = Math.min(320, Math.max(160, viewportH - 32)) const below = viewportH - rect.bottom - sideOffset const above = rect.top - sideOffset // Honor `side` unless it has < 160px while the other side has more room. let s = side if (s === 'bottom' && below < 160 && above > below) s = 'top' if (s === 'top' && above < 160 && below > above) s = 'bottom' resolvedSide = s const height = Math.min(maxHeight, contentEl?.offsetHeight || maxHeight) const top = s === 'bottom' ? rect.bottom + sideOffset : Math.max(8, rect.top - sideOffset - height) const width = Math.max(rect.width, 128) let left = align === 'start' ? rect.left : align === 'end' ? rect.right - width : rect.left + (rect.width - width) / 2 left = Math.min(Math.max(8, left), Math.max(8, viewportW - width - 8)) pos = { top, left, minWidth: rect.width, maxHeight, placed: true } } $effect(() => { if (!ctx.open) { // Reset without tracking `pos`: reading and writing it here re-ran this // effect forever (effect_update_depth_exceeded on every closed Select). untrack(() => { if (pos.placed) pos = { ...pos, placed: false } }) return } // Place after mount so contentEl has measurable height. const raf = requestAnimationFrame(() => { place() contentEl?.focus({ preventScroll: true }) }) const onDocPointerDown = (e: PointerEvent) => { const t = e.target as Node | null if (t && contentEl?.contains(t)) return if (t && ctx.triggerEl?.contains(t)) return ctx.setOpen(false) } const onScroll = () => place() const onResize = () => place() document.addEventListener('pointerdown', onDocPointerDown, { capture: true }) window.addEventListener('scroll', onScroll, { capture: true, passive: true }) window.addEventListener('resize', onResize) return () => { cancelAnimationFrame(raf) document.removeEventListener('pointerdown', onDocPointerDown, { capture: true }) window.removeEventListener('scroll', onScroll, { capture: true }) window.removeEventListener('resize', onResize) } }) // Keep the highlighted option visible while arrowing through the list. $effect(() => { if (!ctx.open || !viewportEl) return const id = ctx.highlighted if (!id) return const el = viewportEl.querySelector(`[data-value="${CSS.escape(id)}"]`) el?.scrollIntoView({ block: 'nearest' }) }) function onKeyDown(e: KeyboardEvent) { if (e.key === 'ArrowDown') { e.preventDefault() ctx.moveHighlight(1) } else if (e.key === 'ArrowUp') { e.preventDefault() ctx.moveHighlight(-1) } else if (e.key === 'Home') { e.preventDefault() ctx.highlightFirst() } else if (e.key === 'End') { e.preventDefault() ctx.highlightLast() } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() if (ctx.highlighted !== undefined) ctx.selectValue(ctx.highlighted) } else if (e.key === 'Escape') { e.preventDefault() ctx.setOpen(false) ctx.focusTrigger() } else if (e.key === 'Tab') { ctx.setOpen(false) } } const activeDescendant = $derived( ctx.highlighted ? `${ctx.contentId}-item-${CSS.escape(ctx.highlighted)}` : undefined, ) // Teleport the listbox to <body> so it escapes overflow clipping and stacks // above page content. (Svelte 5's <svelte:body> takes no children, so the // portal is a plain action — Svelte keeps updating moved nodes.) function portal(node: HTMLElement) { document.body.appendChild(node) return { destroy() { node.remove() }, } } function offDocument(node: HTMLElement) { document.createDocumentFragment().appendChild(node) return { destroy() { node.remove() }, } } </script> {#if ctx.open} <div use:portal bind:this={contentEl} data-uipkge data-slot="select-content" data-state="open" data-side={resolvedSide} role="listbox" tabindex="-1" id={ctx.contentId} aria-labelledby={ctx.triggerId} aria-activedescendant={activeDescendant} aria-orientation="vertical" style="position: fixed; top: {pos.top}px; left: {pos.left}px; min-width: {pos.minWidth}px; max-height: {pos.maxHeight}px; {pos.placed ? '' : 'visibility: hidden;'}" class={cn( 'bg-popover text-popover-foreground motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:ease-emphasized motion-safe:data-[state=open]:blur-in-2 motion-safe:data-[state=closed]:blur-out-2 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 overflow-hidden rounded-md border shadow-md motion-safe:data-[state=closed]:duration-[var(--dur-exit)] motion-safe:data-[state=open]:duration-200', 'data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1', className, )} onkeydown={onKeyDown} {...restProps} > <SelectScrollUpButton /> <div bind:this={viewportEl} data-slot="select-viewport" class="max-h-[inherit] overflow-x-hidden overflow-y-auto scroll-my-1 p-1"> {@render children?.()} </div> <SelectScrollDownButton /> </div> {:else if mounted} <!-- Closed: still mount the items, off-document, so they register their labels and SelectValue can show the selected one (reka-ui does the same). Client-only, like reka-ui: SelectItem uses CSS.escape, absent in SSR. --> <div hidden use:offDocument> {@render children?.()} </div> {/if}src/lib/components/ui/select/SelectGroup.svelte0.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface SelectGroupProps extends HTMLAttributes<HTMLDivElement> { children?: Snippet } </script> <script lang="ts"> let { children, ...restProps }: SelectGroupProps = $props() </script> <div data-uipkge data-slot="select-group" role="group" {...restProps}> {@render children?.()} </div>src/lib/components/ui/select/SelectItem.svelte2.7 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface SelectItemProps extends HTMLAttributes<HTMLDivElement> { value: string disabled?: boolean /** Display label. Defaults to the rendered text content. */ label?: string children?: Snippet /** Replaces the default check icon shown for the selected item. */ indicatorIcon?: Snippet ref?: HTMLDivElement | null } </script> <script lang="ts"> import { Check } from '@lucide/svelte' import { cn } from '$lib/utils' import { getSelectContext } from './select-context' let { class: className, value, disabled = false, label, children, indicatorIcon, ref = $bindable(null), ...restProps }: SelectItemProps = $props() const ctx = getSelectContext('SelectItem') const isSelected = $derived(ctx.value === value) const isHighlighted = $derived(ctx.highlighted === value) $effect(() => { // Tracks value/label/disabled; the cleanup unregisters the previous value. const v = value const l = label const d = disabled if (!ref) return ctx.registerItem({ value: v, label: l ?? ref.textContent?.trim() ?? v, disabled: d }) return () => ctx.unregisterItem(v) }) function onClick() { if (disabled) return ctx.selectValue(value) } </script> <div bind:this={ref} data-uipkge data-slot="select-item" data-value={value} data-highlighted={isHighlighted ? '' : undefined} data-state={isSelected ? 'checked' : 'unchecked'} role="option" id={`${ctx.contentId}-item-${CSS.escape(value)}`} aria-selected={isSelected} aria-disabled={disabled || undefined} data-disabled={disabled ? '' : undefined} class={cn( 'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2', className, )} onclick={onClick} onpointermove={() => { if (!disabled) ctx.setHighlighted(value) }} {...restProps} > <span class="absolute right-2 flex size-3.5 items-center justify-center"> {#if isSelected} {#if indicatorIcon} {@render indicatorIcon()} {:else} <Check class="size-4" aria-hidden="true" /> {/if} {/if} </span> <span data-uipkge data-slot="select-item-text"> {@render children?.()} </span> </div>src/lib/components/ui/select/SelectItemText.svelte0.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface SelectItemTextProps extends HTMLAttributes<HTMLSpanElement> { children?: Snippet } </script> <script lang="ts"> let { children, ...restProps }: SelectItemTextProps = $props() </script> <span data-uipkge data-slot="select-item-text" {...restProps}> {@render children?.()} </span>src/lib/components/ui/select/SelectLabel.svelte0.5 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface SelectLabelProps extends HTMLAttributes<HTMLDivElement> { children?: Snippet } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ...restProps }: SelectLabelProps = $props() </script> <div data-uipkge data-slot="select-label" class={cn('text-muted-foreground px-2 py-2 text-xs', className)} {...restProps}> {@render children?.()} </div>src/lib/components/ui/select/SelectScrollDownButton.svelte1.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface SelectScrollDownButtonProps extends HTMLAttributes<HTMLDivElement> { children?: Snippet } </script> <script lang="ts"> import { ChevronDown } from '@lucide/svelte' import { cn } from '$lib/utils' let { class: className, children, ...restProps }: SelectScrollDownButtonProps = $props() let btnEl: HTMLDivElement | null = $state(null) let timer: ReturnType<typeof setInterval> | null = null function viewport(): HTMLElement | null { return btnEl?.closest('[data-slot="select-content"]')?.querySelector('[data-slot="select-viewport"]') ?? null } function step() { const vp = viewport() if (vp) vp.scrollTop += 24 } function start(e: PointerEvent) { e.preventDefault() step() timer = setInterval(step, 50) } function stop() { if (timer) { clearInterval(timer) timer = null } } </script> <div bind:this={btnEl} data-uipkge data-slot="select-scroll-down-button" aria-hidden="true" class={cn('flex cursor-default items-center justify-center py-1', className)} onpointerdown={start} onpointerup={stop} onpointerleave={stop} {...restProps} > {#if children} {@render children()} {:else} <ChevronDown class="size-4" aria-hidden="true" /> {/if} </div>src/lib/components/ui/select/SelectScrollUpButton.svelte1.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface SelectScrollUpButtonProps extends HTMLAttributes<HTMLDivElement> { children?: Snippet } </script> <script lang="ts"> import { ChevronUp } from '@lucide/svelte' import { cn } from '$lib/utils' let { class: className, children, ...restProps }: SelectScrollUpButtonProps = $props() let btnEl: HTMLDivElement | null = $state(null) let timer: ReturnType<typeof setInterval> | null = null function viewport(): HTMLElement | null { return btnEl?.closest('[data-slot="select-content"]')?.querySelector('[data-slot="select-viewport"]') ?? null } function step() { const vp = viewport() if (vp) vp.scrollTop -= 24 } function start(e: PointerEvent) { e.preventDefault() step() timer = setInterval(step, 50) } function stop() { if (timer) { clearInterval(timer) timer = null } } </script> <div bind:this={btnEl} data-uipkge data-slot="select-scroll-up-button" aria-hidden="true" class={cn('flex cursor-default items-center justify-center py-1', className)} onpointerdown={start} onpointerup={stop} onpointerleave={stop} {...restProps} > {#if children} {@render children()} {:else} <ChevronUp class="size-4" aria-hidden="true" /> {/if} </div>src/lib/components/ui/select/SelectSeparator.svelte0.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface SelectSeparatorProps extends HTMLAttributes<HTMLDivElement> {} </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, ...restProps }: SelectSeparatorProps = $props() </script> <div data-uipkge data-slot="select-separator" role="separator" aria-orientation="horizontal" class={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)} {...restProps} ></div>src/lib/components/ui/select/SelectTrigger.svelte3 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLButtonAttributes } from 'svelte/elements' export interface SelectTriggerProps extends HTMLButtonAttributes { size?: 'sm' | 'default' | 'lg' state?: 'default' | 'error' | 'success' loading?: boolean children?: Snippet ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { ChevronDown, Loader } from '@lucide/svelte' import { cn } from '$lib/utils' import { getSelectContext } from './select-context' let { class: className, size = 'default', state = 'default', loading = false, children, ref = $bindable(null), disabled, ...restProps }: SelectTriggerProps = $props() const ctx = getSelectContext('SelectTrigger') const isDisabled = $derived(disabled || loading || ctx.disabled) const sizeClasses = { sm: 'h-8 text-sm px-2.5 py-1.5', default: 'h-9 text-sm px-3 py-2', lg: 'h-11 text-base px-4 py-2.5', } const stateClasses = { default: 'border-input dark:hover:bg-input/50', error: 'border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', success: 'border-success focus-visible:border-success', } $effect(() => { ctx.registerTrigger(ref) return () => ctx.registerTrigger(null) }) function onClick() { if (isDisabled) return ctx.setOpen(!ctx.open) } function onKeyDown(e: KeyboardEvent) { if (isDisabled) return if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault() ctx.setOpen(true) } else if (e.key === 'ArrowUp') { e.preventDefault() ctx.setOpen(true) ctx.highlightLast() } else if (e.key === 'Escape' && ctx.open) { e.preventDefault() ctx.setOpen(false) } } </script> <button bind:this={ref} type="button" data-uipkge data-slot="select-trigger" data-size={size} data-state-value={state} data-state={ctx.open ? 'open' : 'closed'} aria-haspopup="listbox" aria-expanded={ctx.open} aria-controls={ctx.contentId} aria-busy={loading} id={ctx.triggerId} disabled={isDisabled} class={cn( 'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground 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:bg-input/30 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', sizeClasses[size], stateClasses[state], className, )} onclick={onClick} onkeydown={onKeyDown} {...restProps} > {@render children?.()} {#if !loading} <ChevronDown class="size-4 opacity-50" aria-hidden="true" /> {:else} <Loader class="size-4 animate-spin opacity-50" aria-hidden="true" /> {/if} </button>src/lib/components/ui/select/SelectValue.svelte0.9 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface SelectValueProps extends HTMLAttributes<HTMLSpanElement> { placeholder?: string children?: Snippet } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getSelectContext } from './select-context' let { class: className, placeholder, children, ...restProps }: SelectValueProps = $props() const ctx = getSelectContext('SelectValue') const selected = $derived(ctx.items.find((i) => i.value === ctx.value)) </script> <span data-uipkge data-slot="select-value" data-placeholder={selected ? undefined : ''} class={cn('pointer-events-none truncate', className)} {...restProps} > {#if children} {@render children()} {:else if selected} {selected.label} {:else} {placeholder} {/if} </span>src/lib/components/ui/select/NativeSelect.svelte3.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes, HTMLSelectAttributes } from 'svelte/elements' export interface NativeSelectOption { label: string value: string | number disabled?: boolean } export interface NativeSelectProps extends HTMLAttributes<HTMLDivElement> { value?: string | number defaultValue?: string | number options?: (NativeSelectOption | string)[] /** Primary sizing API (React parity). Wins over `size` when both are set. */ sizeVariant?: 'sm' | 'md' | 'lg' /** @deprecated Use `sizeVariant`. Kept as an alias — both stay functional. */ size?: 'sm' | 'md' | 'lg' disabled?: boolean selectClass?: string /** Native select passthrough attributes (name, required, aria-*). */ selectAttributes?: HTMLSelectAttributes onValueChange?: (value: string) => void children?: Snippet ref?: HTMLDivElement | null } </script> <script lang="ts"> import { ChevronDown } from '@lucide/svelte' import { cn } from '$lib/utils' let { class: className, value = $bindable<string | number | undefined>(), defaultValue, options, sizeVariant, size, disabled = false, selectClass, selectAttributes, onValueChange, children, ref = $bindable(null), ...restProps }: NativeSelectProps = $props() const effectiveSize = $derived(sizeVariant ?? size ?? 'md') let inner = $state<string | number | undefined>(defaultValue) const current = $derived(value ?? inner) const sizeClasses: Record<string, string> = { sm: 'h-8 text-xs pl-2.5 pr-8', md: 'h-9 text-sm pl-3 pr-9', lg: 'h-11 text-base pl-4 pr-10', } const iconSizes: Record<string, string> = { sm: 'size-3.5 right-2.5', md: 'size-4 right-3', lg: 'size-5 right-3.5', } const normalizedOptions = $derived.by(() => { if (!options) return [] return options.map((opt) => { if (typeof opt === 'string') { return { label: opt, value: opt, disabled: false } } return opt }) }) function onChange(e: Event) { const next = (e.target as HTMLSelectElement).value inner = next value = next onValueChange?.(next) } </script> <div bind:this={ref} data-uipkge data-slot="native-select-wrapper" class={cn('relative inline-flex w-full items-center', className)} {...restProps}> <select data-slot="native-select" {disabled} value={current} class={cn( 'border-input bg-background w-full appearance-none rounded-md border shadow-xs transition-[color,box-shadow]', 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none', 'disabled:bg-muted/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50', sizeClasses[effectiveSize], selectClass, )} onchange={onChange} {...selectAttributes} > {#if normalizedOptions.length > 0} {#each normalizedOptions as opt (String(opt.value))} <option value={opt.value} disabled={opt.disabled}> {opt.label} </option> {/each} {:else} {@render children?.()} {/if} </select> <ChevronDown data-slot="native-select-icon" aria-hidden="true" class={cn('text-muted-foreground pointer-events-none absolute transition-opacity', disabled && 'opacity-50', iconSizes[effectiveSize])} /> </div>src/lib/components/ui/select/index.ts1.2 kB
export { default as Select, type SelectProps } from './Select.svelte' export { default as SelectContent, type SelectContentProps } from './SelectContent.svelte' export { default as SelectGroup, type SelectGroupProps } from './SelectGroup.svelte' export { default as SelectItem, type SelectItemProps } from './SelectItem.svelte' export { default as SelectItemText, type SelectItemTextProps } from './SelectItemText.svelte' export { default as SelectLabel, type SelectLabelProps } from './SelectLabel.svelte' export { default as SelectScrollDownButton, type SelectScrollDownButtonProps } from './SelectScrollDownButton.svelte' export { default as SelectScrollUpButton, type SelectScrollUpButtonProps } from './SelectScrollUpButton.svelte' export { default as SelectSeparator, type SelectSeparatorProps } from './SelectSeparator.svelte' export { default as SelectTrigger, type SelectTriggerProps } from './SelectTrigger.svelte' export { default as SelectValue, type SelectValueProps } from './SelectValue.svelte' export { default as NativeSelect, type NativeSelectProps, type NativeSelectOption } from './NativeSelect.svelte' export { type SelectOption, readKey } from './option-types' export { SELECT_CONTEXT_KEY, type SelectContext, type SelectItemData } from './select-context'src/lib/components/ui/select/option-types.ts0.7 kB
/** * Shared option shape for `AdvanceSelect` and any future options-driven select. * Components accept this canonical shape OR any object shape if `value-key` / * `label-key` are provided. */ export interface SelectOption<V = string> { label: string value: V disabled?: boolean /** Items sharing this key render under one heading. */ group?: string } /** * Resolve `option[key]` with a default fallback. Used by AdvanceSelect so every * accessor obeys `value-key` / `label-key`. */ export function readKey<T>(option: T, key: string, fallback?: unknown): unknown { if (option == null || typeof option !== 'object') return fallback const v = (option as Record<string, unknown>)[key] return v === undefined ? fallback : v }src/lib/components/ui/select/select-context.ts1.3 kB
import { getContext } from 'svelte' export interface SelectItemData { value: string label: string disabled: boolean } /** * Shared select state, provided by `Select` and consumed by the parts. * Readonly fields are implemented as getters over runes state in `Select`, * so `$derived(ctx.open)` in a part stays reactive. */ export 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 } export const SELECT_CONTEXT_KEY = Symbol('uipkge-select-context') export function getSelectContext(component: string): SelectContext { const ctx = getContext<SelectContext | undefined>(SELECT_CONTEXT_KEY) if (!ctx) throw new Error(`${component} must be used inside <Select>.`) return ctx }
Raw manifest:https://uipkge.dev/r/svelte/select.json