Glrk UI

Select

A wrapper for Base UI Select with flexible option types, grouped options, backdrop, custom value rendering, and multiple selection.

Basic
Flat list
Grouped options
With separator
Indicator
Right (default)
Left
State
Default value — Mango pre-selected
Disabled root — non-interactive
Disabled item — Viewer unreachable
Read-only — value visible, not editable
Multiple
Multiple — pick several values
Selected: none
Controlled
Controlled value via useState
Value:
Custom Style
Icon items — theme switcher
Per-item colour via className
Form — native submission with name/required
Render Value
Status badge — dot + coloured label in trigger
Assignee — avatar + name + dept in trigger, rich list

Installation

npx shadcn@latest add @glrk-ui/select

If you haven't set up the prerequisites yet, check out Prerequest section.

Copy and paste the following code into your project.

types/general.d.ts
type allowedPrimitiveT = string | number | boolean

type optionT = {
  label: React.ReactNode
  value: allowedPrimitiveT
  className?: string
  disabled?: boolean
}

type groupT = {
  group: string
  options: (allowedPrimitiveT | optionT)[]
  className?: string
}

type optionsT = (allowedPrimitiveT | optionT | groupT)[]

type indicatorAtT = 'right' | 'left'
lib/utils.ts
import { isValidElement, type ReactNode } from 'react'
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

export function isAllowedPrimitive(value: unknown): value is allowedPrimitiveT {
  return ['string', 'number', 'boolean'].includes(typeof value)
}

export function parseAllowedPrimitive(value: allowedPrimitiveT): allowedPrimitiveT {
  if (typeof value !== 'string') return value

  const trimmed = value.trim()

  if (trimmed === 'true') return true
  if (trimmed === 'false') return false
  if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)

  return trimmed
}

export function optionTypeChecker<T>(key: keyof T) {
  return (option: any): option is T => !!option && typeof option === 'object' && key in option
}

export const isSeparator = (item: any) => item === '---'
export const isOption = optionTypeChecker<optionT>('value')
export const isGroup = optionTypeChecker<groupT>('group')

export const getValue = (item: allowedPrimitiveT | optionT) =>
  typeof item === 'object' ? item.value : item
export const getLabel = (item: allowedPrimitiveT | optionT) =>
  typeof item === 'object' ? item.label : `${item}`

export function getKey(item: allowedPrimitiveT | optionT, i: number): string {
  const val = getValue(item)
  if (typeof val === 'boolean') return `key-${val}`
  if (val === '---') return `---${i}`
  return `${val}`
}

export function extractText(node: ReactNode): string {
  if (node == null || typeof node === 'boolean') return ''
  if (typeof node === 'string' || typeof node === 'number') return String(node)
  if (isValidElement(node)) return extractText((node.props as { children?: ReactNode }).children)
  if (Array.isArray(node)) return node.map(extractText).join('')
  return ''
}
ui/select.tsx
'use client'

import * as React from 'react'
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react'
import { Select as SelectPrimitive } from '@base-ui/react/select'

import { cn, getKey, getLabel, getValue, isGroup, isOption, isSeparator } from '@/lib/utils'

const Select = SelectPrimitive.Root

function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
  return (
    <SelectPrimitive.Group
      data-slot="select-group"
      className={cn('scroll-my-1 p-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, children, ...props }: SelectPrimitive.Trigger.Props) {
  return (
    <SelectPrimitive.Trigger
      data-slot="select-trigger"
      className={cn(
        "flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent pl-3 pr-1 h-9 text-sm shadow-xs whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground *: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 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className,
      )}
      {...props}
    >
      {children}
      <SelectPrimitive.Icon
        render={<span className="flex size-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" />}
      >
        <ChevronDownIcon className="size-4" />
      </SelectPrimitive.Icon>
    </SelectPrimitive.Trigger>
  )
}

function SelectBackdrop({ className, ...props }: SelectPrimitive.Backdrop.Props) {
  return (
    <SelectPrimitive.Backdrop
      data-slot="select-backdrop"
      className={cn('fixed inset-0', className)}
      {...props}
    />
  )
}

function SelectContent({
  className,
  children,
  side = 'bottom',
  sideOffset = 4,
  align = 'center',
  alignOffset = 0,
  alignItemWithTrigger = false,
  backdrop = false,
  ...props
}: SelectPrimitive.Popup.Props &
  Pick<
    SelectPrimitive.Positioner.Props,
    'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'
  > & { backdrop?: boolean }) {
  return (
    <SelectPrimitive.Portal>
      {backdrop && <SelectBackdrop />}
      <SelectPrimitive.Positioner
        side={side}
        sideOffset={sideOffset}
        align={align}
        alignOffset={alignOffset}
        alignItemWithTrigger={alignItemWithTrigger}
      >
        <SelectPrimitive.Popup
          data-slot="select-content"
          data-align-trigger={alignItemWithTrigger}
          className={cn(
            'relative p-1 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 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',
            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 py-1.5 text-xs font-medium text-muted-foreground', className)}
      {...props}
    />
  )
}

function SelectItem({
  className,
  children,
  indicatorAt = 'right',
  ...props
}: SelectPrimitive.Item.Props & { indicatorAt?: indicatorAtT }) {
  return (
    <SelectPrimitive.Item
      data-slot="select-item"
      className={cn(
        "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground 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,
        indicatorAt === 'right' ? 'pr-8 pl-2' : 'pr-2 pl-8',
      )}
      {...props}
    >
      <SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
        {children}
      </SelectPrimitive.ItemText>

      <SelectPrimitive.ItemIndicator
        className={cn(
          'pointer-events-none absolute flex size-4 items-center justify-center',
          indicatorAt === 'right' ? 'right-2' : 'left-2',
        )}
      >
        <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 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-1 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
        className,
      )}
      {...props}
    >
      <ChevronUpIcon />
    </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-1 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
        className,
      )}
      {...props}
    >
      <ChevronDownIcon />
    </SelectPrimitive.ScrollDownArrow>
  )
}

type itemProps = {
  option: allowedPrimitiveT | optionT
  className?: string
  indicatorAt?: indicatorAtT
}

function Item({ option, className, indicatorAt }: itemProps) {
  const value = getValue(option)
  const label = getLabel(option)
  const optCls = isOption(option) ? option.className : undefined
  const disabled = isOption(option) ? option.disabled : undefined

  if (isSeparator(value)) return <SelectSeparator className={className} />

  return (
    <SelectItem value={`${value}`} className={cn(className, optCls)} indicatorAt={indicatorAt} disabled={disabled}>
      {label}
    </SelectItem>
  )
}

type selectProps = {
  id?: string
  options: optionsT
  placeholder?: string
  indicatorAt?: indicatorAtT
  backdrop?: boolean
  triggerCls?: string
  contentCls?: string
  groupCls?: string
  groupLabelCls?: string
  itemCls?: string
  renderValue?: (value: string, option: allowedPrimitiveT | optionT | undefined) => React.ReactNode
} & React.ComponentProps<typeof SelectPrimitive.Root>
function SelectWrapper({
  id,
  options,
  placeholder,
  indicatorAt,
  backdrop,
  triggerCls,
  contentCls,
  groupCls,
  itemCls,
  groupLabelCls,
  renderValue,
  ...props
}: selectProps) {
  const { labelMap, optionMap } = React.useMemo(() => {
    const labelMap: Record<string, React.ReactNode> = {}
    const optionMap: Record<string, allowedPrimitiveT | optionT> = {}
    const process = (opts: optionsT) => {
      for (const opt of opts) {
        if (isGroup(opt)) {
          process(opt.options as optionsT)
        } else {
          const o = opt as allowedPrimitiveT | optionT
          const val = getValue(o)
          if (!isSeparator(val)) {
            const key = String(val)
            labelMap[key] = getLabel(o)
            optionMap[key] = o
          }
        }
      }
    }
    process(options)
    return { labelMap, optionMap }
  }, [options])

  return (
    <Select {...props}>
      <SelectTrigger
        id={id}
        className={cn(
          'w-full',
          props.multiple && 'h-auto min-h-9 py-1 *:data-[slot=select-value]:flex-wrap',
          triggerCls,
        )}
      >
        <SelectValue placeholder={placeholder}>
          {(value: string | string[] | null) => {
            if (!value || (Array.isArray(value) && !value.length)) return placeholder
            if (Array.isArray(value)) {
              return value.map(v => (
                <span key={v} className="inline-flex items-center rounded-sm bg-secondary px-1.5 py-1 text-xs font-medium text-secondary-foreground whitespace-nowrap">
                  {renderValue ? renderValue(v, optionMap[v]) : (labelMap[v] ?? v)}
                </span>
              ))
            }
            if (renderValue) return renderValue(value, optionMap[value])
            return labelMap[value] ?? value
          }}
        </SelectValue>
      </SelectTrigger>

      <SelectContent backdrop={backdrop} className={cn(contentCls)}>
        {options.map((option, i) => {
          if (isGroup(option)) {
            return (
              <SelectGroup key={option.group} className={cn(groupCls, option.className)}>
                <SelectLabel className={cn('pb-0.5', groupLabelCls)}>{option.group}</SelectLabel>

                {option.options.map((grOpts, j) => (
                  <Item
                    key={getKey(grOpts, j)}
                    option={grOpts}
                    className={cn('pl-4', itemCls)}
                    indicatorAt={indicatorAt}
                  />
                ))}
              </SelectGroup>
            )
          }

          return (
            <Item
              key={getKey(option, i)}
              option={option}
              className={itemCls}
              indicatorAt={indicatorAt}
            />
          )
        })}
      </SelectContent>
    </Select>
  )
}

export {
  Select,
  SelectBackdrop,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectScrollDownButton,
  SelectScrollUpButton,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
  SelectWrapper,
  type selectProps,
}

Usage

Basic

import { SelectWrapper } from "@/components/ui/select"

<SelectWrapper
  options={["Apple", "Banana", "Orange"]}
  placeholder="Select a fruit"
/>

Options format

options accepts primitives, option objects, groups, and separators — mixed freely:

const options = [
  "Simple string",
  42,
  true,
  "---",                                          // separator
  { label: "Custom label", value: "custom" },
  { label: <><SunIcon /> Light</>, value: "light" }, // ReactNode label
  { label: "Disabled", value: "off", disabled: true },
  {
    group: "Category",
    options: ["Option A", "Option B"],
  },
]

<SelectWrapper options={options} placeholder="Pick one" />

Grouped options

<SelectWrapper
  options={[
    { group: "Fruits", options: ["Apple", "Banana"] },
    { group: "Veggies", options: ["Carrot", "Broccoli"] },
  ]}
  placeholder="Select food"
/>

Indicator placement

<SelectWrapper options={options} indicatorAt="left" placeholder="Left check" />
<SelectWrapper options={options} indicatorAt="right" placeholder="Right check" /> {/* default */}

Disabled item

Pass disabled: true on any option object:

<SelectWrapper
  options={[
    { label: "Available", value: "a" },
    { label: "Unavailable", value: "b", disabled: true },
  ]}
  placeholder="Select"
/>

Backdrop

Renders a fixed backdrop behind the dropdown — useful for modal-style behaviour:

<SelectWrapper options={options} backdrop placeholder="Select with backdrop" />

Controlled

const [value, setValue] = useState("apple")

<SelectWrapper
  options={["apple", "banana", "orange"]}
  value={value}
  onValueChange={setValue}
/>

With defaultValue (uncontrolled):

<SelectWrapper options={options} defaultValue="banana" />

Custom value rendering

Use renderValue to customise what appears in the trigger after selection. Receives the raw value string and the matched option object:

<SelectWrapper
  options={[
    { label: <><SunIcon /> Light</>, value: "light" },
    { label: <><MoonIcon /> Dark</>, value: "dark" },
  ]}
  placeholder="Theme"
  renderValue={(value, option) => (
    <span className="flex items-center gap-1.5">
      {value === "light" ? <SunIcon className="size-4" /> : <MoonIcon className="size-4" />}
      {value}
    </span>
  )}
/>

SelectWrapper automatically mirrors item labels (including ReactNode) into the trigger via SelectValue. renderValue is only needed when you want a different representation in the trigger than in the list.

Custom styling

<SelectWrapper
  options={options}
  placeholder="Styled"
  triggerCls="w-64 bg-muted"
  contentCls="min-w-64"
  groupLabelCls="text-primary font-semibold"
  groupCls="py-1"
  itemCls="rounded-none"
/>

Using primitives

import {
  Select,
  SelectTrigger,
  SelectValue,
  SelectContent,
  SelectGroup,
  SelectLabel,
  SelectItem,
  SelectSeparator,
} from "@/components/ui/select"

<Select>
  <SelectTrigger>
    <SelectValue placeholder="Pick one" />
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      <SelectLabel>Fruits</SelectLabel>
      <SelectItem value="apple">Apple</SelectItem>
      <SelectItem value="banana">Banana</SelectItem>
    </SelectGroup>
    <SelectSeparator />
    <SelectItem value="other" indicatorAt="left">Other</SelectItem>
  </SelectContent>
</Select>

Reference

Prop

Type