Skip to content
edmi-ui

Select

Custom select in a popover with groups, labels, separators and a check on the chosen item.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Terminal window
npx shadcn@latest add @edmi-ui/select
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/select
Terminal window
yarn dlx shadcn@latest add @edmi-ui/select
Terminal window
bunx shadcn@latest add @edmi-ui/select

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/select
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/select
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/select
Terminal window
bunx shadcn-vue@latest add @edmi-ui/select

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/select.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/select.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/select.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/select.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
<Select defaultValue="nvdax">
<SelectTrigger className="w-52">
<SelectValue placeholder="Select a token" />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectLabel>US megacaps</SelectLabel>
<SelectItem value="nvdax">NVDAx</SelectItem>
<SelectItem value="msftx">MSFTx</SelectItem>
<SelectItem value="aaplx">AAPLx</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Pre-IPO</SelectLabel>
<SelectItem value="anthrop">ANTHRP-pre</SelectItem>
<SelectItem value="openai" disabled>
OPENAI-pre
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<script setup lang="ts">
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
</script>
<template>
<Select default-value="nvdax">
<SelectTrigger class="w-52">
<SelectValue placeholder="Select a token" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>US megacaps</SelectLabel>
<SelectItem value="nvdax">NVDAx</SelectItem>
<SelectItem value="msftx">MSFTx</SelectItem>
<SelectItem value="aaplx">AAPLx</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Pre-IPO</SelectLabel>
<SelectItem value="anthrop">ANTHRP-pre</SelectItem>
<SelectItem value="openai" disabled>OPENAI-pre</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</template>
<script lang="ts">
import {
Select,
SelectContent,
SelectGroup,
SelectGroupHeading,
SelectItem,
SelectSeparator,
SelectTrigger,
} from "$lib/components/ui/select";
</script>
<Select type="single" bind:value>
<SelectTrigger class="w-52">
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectGroupHeading>US megacaps</SelectGroupHeading>
<SelectItem value="nvdax" label="NVDAx" />
<SelectItem value="msftx" label="MSFTx" />
<SelectItem value="aaplx" label="AAPLx" />
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectGroupHeading>Pre-IPO</SelectGroupHeading>
<SelectItem value="anthrop" label="ANTHRP-pre" />
<SelectItem value="openai" label="OPENAI-pre" disabled />
</SelectGroup>
</SelectContent>
</Select>
registry/ui/select.tsx
"use client";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cn } from "cn";
import type * as React from "react";
import { CaretDownIcon, CheckIcon, CaretUpIcon } from "@phosphor-icons/react";
import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation";
// ✦ depth (v4): fields sink (-1) in layered mode; focus swaps the edge for the ring
const fieldElevation = {
sunken:
"border-sk-bd bg-sk-bg shadow-sunken data-[popup-open]:bg-card focus-visible:bg-card",
flat: "",
raised:
"border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-raised focus-visible:border-ring focus-visible:shadow-ring",
floating:
"border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-floating focus-visible:border-ring focus-visible:shadow-ring",
};
// ✦ depth (v4): overlay role for the menu popup. Natural level is floating in layered mode; flat otherwise.
const overlayElevation = {
sunken: "",
flat: "",
raised: "border-transparent shadow-raised",
floating: "border-transparent shadow-floating",
};
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1", className)}
{...props}
/>
);
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
);
}
function SelectTrigger({
className,
size = "default",
elevation,
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default";
/** ✦ depth for the trigger (the popup stays floating): sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
}) {
const level = useElevation(elevation, "field");
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-card pr-2.5 pl-3 text-sm whitespace-nowrap text-foreground outline-none select-none focus-visible:border-ring focus-visible:shadow-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:shadow-ring-error data-[popup-open]:border-ring data-[popup-open]:shadow-ring data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 data-[size=sm]:rounded-[7px] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
fieldElevation[level],
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<CaretDownIcon
className="pointer-events-none size-4 text-muted-foreground"
/>
}
/>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
elevation,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
> & {
/** ✦ depth of the menu popup: flat 0, raised +1 (bevel), floating +2 (bevel + drop). */
elevation?: Elevation;
}) {
const level = useElevation(elevation, "overlay");
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border border-border bg-popover p-1.5 text-popover-foreground duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[open]:animate-in data-[open]:fade-in-0 data-[open]:zoom-in-95 data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95",
overlayElevation[level],
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn(
"px-2 pt-1.5 pb-1 text-xs font-semibold text-muted-foreground",
className,
)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex h-8 w-full cursor-default items-center gap-2.5 rounded-[7px] pr-8 pl-2 text-[13.5px] outline-hidden select-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-45 [&_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,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 items-center gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon
className="pointer-events-none"
/>
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
"pointer-events-none -mx-1.5 my-1 h-px bg-border",
className,
)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<CaretUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<CaretDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
registry/ui/select/Select.vue
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot
v-slot="slotProps"
data-slot="select"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</SelectRoot>
</template>
registry/ui/select/SelectContent.vue
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
import { type Elevation, useElevation } from '@/registry/edmi/ui/elevation'
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes['class'], elevation?: Elevation }>(),
{
elevation: undefined,
position: 'popper',
sideOffset: 4,
},
)
const emits = defineEmits<SelectContentEmits>()
// ✦ depth (v4): overlay role. Natural level is floating in layered mode; flat otherwise.
const overlayElevation = {
sunken: '',
flat: '',
raised: 'border-transparent shadow-raised',
floating: 'border-transparent shadow-floating',
}
const level = useElevation(() => props.elevation, 'overlay')
const delegatedProps = reactiveOmit(props, 'class', 'elevation')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'relative isolate z-50 max-h-(--reka-select-content-available-height) min-w-36 origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border border-border bg-popover text-popover-foreground duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
overlayElevation[level],
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport :class="cn('p-1.5', position === 'popper' && 'w-full min-w-(--reka-select-trigger-width) scroll-my-1')">
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>
registry/ui/select/SelectGroup.vue
<script setup lang="ts">
import type { SelectGroupProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SelectGroup } from "reka-ui"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<SelectGroup
data-slot="select-group"
v-bind="delegatedProps"
:class="cn('scroll-my-1', props.class)"
>
<slot />
</SelectGroup>
</template>
registry/ui/select/SelectItem.vue
<script setup lang="ts">
import type { SelectItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { Check } from '@lucide/vue'
import { reactiveOmit } from '@vueuse/core'
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
`relative flex h-8 w-full cursor-default items-center gap-2.5 rounded-[7px] pr-8 pl-2 text-[13.5px] outline-hidden select-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-45 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2`,
props.class,
)
"
>
<SelectItemText class="flex flex-1 shrink-0 items-center gap-2 whitespace-nowrap">
<slot />
</SelectItemText>
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<Check class="pointer-events-none size-4" />
</slot>
</SelectItemIndicator>
</span>
</SelectItem>
</template>
registry/ui/select/SelectItemText.vue
<script setup lang="ts">
import type { SelectItemTextProps } from "reka-ui"
import { SelectItemText } from "reka-ui"
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText
data-slot="select-item-text"
v-bind="props"
>
<slot />
</SelectItemText>
</template>
registry/ui/select/SelectLabel.vue
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { SelectLabel } from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('px-2 pt-1.5 pb-1 text-xs font-semibold text-muted-foreground', props.class)"
>
<slot />
</SelectLabel>
</template>
registry/ui/select/SelectScrollDownButton.vue
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { ChevronDown } from '@lucide/vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollDownButton
data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="cn('z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
>
<slot>
<ChevronDown />
</slot>
</SelectScrollDownButton>
</template>
registry/ui/select/SelectScrollUpButton.vue
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { ChevronUp } from '@lucide/vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollUpButton
data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="cn('z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
>
<slot>
<ChevronUp />
</slot>
</SelectScrollUpButton>
</template>
registry/ui/select/SelectSeparator.vue
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectSeparator } from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('pointer-events-none -mx-1.5 my-1 h-px bg-border', props.class)"
/>
</template>
registry/ui/select/SelectTrigger.vue
<script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { ChevronDown } from '@lucide/vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
import { type Elevation, useElevation } from '@/registry/edmi/ui/elevation'
const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'], size?: 'sm' | 'default', elevation?: Elevation }>(),
{ size: 'default', elevation: undefined },
)
const delegatedProps = reactiveOmit(props, 'class', 'size', 'elevation')
// ✦ depth (v4): fields sink (-1) in layered mode; focus swaps the edge for the ring
const fieldElevation = {
sunken: 'border-sk-bd bg-sk-bg shadow-sunken data-[state=open]:bg-card focus-visible:bg-card',
flat: '',
raised: 'border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-raised focus-visible:border-ring focus-visible:shadow-ring data-[state=open]:border-ring data-[state=open]:shadow-ring',
floating: 'border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-floating focus-visible:border-ring focus-visible:shadow-ring data-[state=open]:border-ring data-[state=open]:shadow-ring',
}
const level = useElevation(() => props.elevation, 'field')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="cn(
`flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-card pr-2.5 pl-3 text-sm whitespace-nowrap text-foreground outline-none select-none focus-visible:border-ring focus-visible:shadow-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:shadow-ring-error data-[state=open]:border-ring data-[state=open]:shadow-ring data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 data-[size=sm]:rounded-[7px] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
fieldElevation[level],
props.class,
)"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="pointer-events-none size-4 text-muted-foreground" />
</SelectIcon>
</SelectTrigger>
</template>
registry/ui/select/SelectValue.vue
<script setup lang="ts">
import type { SelectValueProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SelectValue } from "reka-ui"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<SelectValueProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<SelectValue
data-slot="select-value"
v-bind="delegatedProps"
:class="cn('flex flex-1 text-left', props.class)"
>
<slot />
</SelectValue>
</template>
registry/ui/select/index.ts
export { default as Select } from "./Select.vue";
export { default as SelectContent } from "./SelectContent.vue";
export { default as SelectGroup } from "./SelectGroup.vue";
export { default as SelectItem } from "./SelectItem.vue";
export { default as SelectItemText } from "./SelectItemText.vue";
export { default as SelectLabel } from "./SelectLabel.vue";
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue";
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue";
export { default as SelectSeparator } from "./SelectSeparator.vue";
export { default as SelectTrigger } from "./SelectTrigger.vue";
export { default as SelectValue } from "./SelectValue.vue";
src/lib/registry/ui/select/select-content.svelte
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js";
import SelectPortal from "./select-portal.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
elevation = "auto",
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
/** ✦ depth: flat 0, raised +1 (bevel), floating +2 (bevel + drop). */
elevation?: Elevation;
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
// ✦ depth (v4): overlay role. Natural level is floating in layered mode; flat otherwise.
const overlayElevation = {
sunken: "",
flat: "",
raised: "border-transparent shadow-raised",
floating: "border-transparent shadow-floating",
};
const level = useElevation(() => elevation, "overlay");
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
"relative isolate z-50 max-h-(--bits-select-content-available-height) min-w-36 origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border border-border bg-popover text-popover-foreground duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
overlayElevation[level.current],
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1.5"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>
src/lib/registry/ui/select/select-group-heading.svelte
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("px-2 pt-1.5 pb-1 text-xs font-semibold text-muted-foreground", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>
src/lib/registry/ui/select/select-group.svelte
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group
bind:ref
data-slot="select-group"
class={cn("scroll-my-1", className)}
{...restProps}
/>
src/lib/registry/ui/select/select-item.svelte
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import CheckIcon from "phosphor-svelte/lib/Check";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
{label}
data-slot="select-item"
class={cn(
"relative flex h-8 w-full cursor-default items-center gap-2.5 rounded-[7px] pr-8 pl-2 text-[13.5px] outline-hidden select-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-45 [&_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
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
{#if selected}
<CheckIcon
class="pointer-events-none"
/>
{/if}
</span>
<span class="flex flex-1 shrink-0 items-center gap-2 whitespace-nowrap">
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
</span>
{/snippet}
</SelectPrimitive.Item>
src/lib/registry/ui/select/select-label.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("px-2 pt-1.5 pb-1 text-xs font-semibold text-muted-foreground", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/select/select-portal.svelte
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />
src/lib/registry/ui/select/select-scroll-down-button.svelte
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import CaretDownIcon from "phosphor-svelte/lib/CaretDown";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4", className)}
{...restProps}
>
<CaretDownIcon
/>
</SelectPrimitive.ScrollDownButton>
src/lib/registry/ui/select/select-scroll-up-button.svelte
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import CaretUpIcon from "phosphor-svelte/lib/CaretUp";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4", className)}
{...restProps}
>
<CaretUpIcon
/>
</SelectPrimitive.ScrollUpButton>
src/lib/registry/ui/select/select-separator.svelte
<script lang="ts">
import { Separator } from "$lib/registry/ui/separator/index.js";
import { cn } from "$lib/utils.js";
import type { Separator as SeparatorPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("pointer-events-none -mx-1.5 my-1 h-px bg-border", className)}
{...restProps}
/>
src/lib/registry/ui/select/select-trigger.svelte
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import CaretDownIcon from "phosphor-svelte/lib/CaretDown";
import { cn, type WithoutChild } from "$lib/utils.js";
import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
elevation = "auto",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
/** ✦ depth for the trigger (popup stays floating): sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
} = $props();
// ✦ depth (v4): fields sink (-1) in layered mode; focus swaps the edge for the ring
const fieldElevation = {
sunken: "border-sk-bd bg-sk-bg shadow-sunken data-[state=open]:bg-card focus-visible:bg-card",
flat: "",
raised: "border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-raised focus-visible:border-ring focus-visible:shadow-ring data-[state=open]:border-ring data-[state=open]:shadow-ring",
floating: "border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-floating focus-visible:border-ring focus-visible:shadow-ring data-[state=open]:border-ring data-[state=open]:shadow-ring",
};
const level = useElevation(() => elevation, "field");
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-card pr-2.5 pl-3 text-sm whitespace-nowrap text-foreground outline-none select-none focus-visible:border-ring focus-visible:shadow-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:shadow-ring-error data-[state=open]:border-ring data-[state=open]:shadow-ring data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 data-[size=sm]:rounded-[7px] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
fieldElevation[level.current],
className
)}
{...restProps}
>
{@render children?.()}
<CaretDownIcon
class="pointer-events-none size-4 text-muted-foreground"
/>
</SelectPrimitive.Trigger>
src/lib/registry/ui/select/select-value.svelte
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SelectPrimitive.ValueProps = $props();
</script>
<SelectPrimitive.Value
bind:ref
data-slot="select-value"
class={cn("flex flex-1 text-left", className)}
{...restProps}
/>
src/lib/registry/ui/select/select.svelte
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />
src/lib/registry/ui/select/index.ts
import Root from "./select.svelte";
import Content from "./select-content.svelte";
import Group from "./select-group.svelte";
import GroupHeading from "./select-group-heading.svelte";
import Item from "./select-item.svelte";
import Label from "./select-label.svelte";
import Portal from "./select-portal.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import Separator from "./select-separator.svelte";
import Trigger from "./select-trigger.svelte";
import Value from "./select-value.svelte";
export {
Content,
Content as SelectContent,
Group,
Group as SelectGroup,
GroupHeading,
GroupHeading as SelectGroupHeading,
Item,
Item as SelectItem,
Label,
Label as SelectLabel,
Portal,
Portal as SelectPortal,
Root,
//
Root as Select,
ScrollDownButton,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton,
ScrollUpButton as SelectScrollUpButton,
Separator,
Separator as SelectSeparator,
Trigger,
Trigger as SelectTrigger,
Value,
Value as SelectValue,
};

See the Elevation guide for the levels, roles and layered mode.

elevation on SelectTrigger takes sunken -1, flat, raised +1 or floating +2. The popup (SelectContent) takes its own elevation (flat by default; floating is its natural level in layered mode).

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Prop Type Default
SelectTrigger size sm | default default
SelectContent alignItemWithTrigger boolean true
value / defaultValue string none
items (React) { value, label }[] none
SelectTrigger elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
SelectContent elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
  • Flat popover surface; elevation on the trigger and on SelectContent are independent; highlighted item uses the accent fill.
  • React: Base UI shows the raw value in SelectValue unless the root gets items={[{ value, label }]} (or SelectValue a render function), so pass items with the labels. Vue and Svelte show the selected item text automatically.
  • Svelte: the root requires type="single" (or "multiple"); React and Vue infer single selection.