Select

A select displays a collapsible list of options and allows a user to select one of them.

"use client"

import * as React from "react"
import { cn } from "cn"
import {
  Button as ButtonPrimitive,
  composeRenderProps,
  Header as HeaderPrimitive,
  ListBoxItem as ListBoxItemPrimitive,
  ListBox as ListBoxPrimitive,
  ListBoxSection as ListBoxSectionPrimitive,
  Popover as PopoverPrimitive,
  SearchField,
  Select as SelectPrimitive,
  SelectValue as SelectValuePrimitive,
  SelectionIndicator,
  Separator as SeparatorPrimitive,
  type ListBoxProps,
  type SearchFieldProps,
  type ListBoxSectionProps as SelectGroupProps,
  type SelectProps,
  type SelectValueProps,
} from "@animate-ui/react/primitives/react-aria"
import { AnimateUIProvider } from "@animate-ui/react/provider"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import { SearchIcon, Tick02Icon, UnfoldMoreIcon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"

function Select<T extends object, M extends "single" | "multiple" = "single">({
  className,
  ...props
}: SelectProps<T, M>) {
  return (
    <SelectPrimitive
      data-slot="select"
      className={cn("w-fit", className)}
      {...props}
    />
  )
}

function SelectGroup<T extends object>({
  className,
  ...props
}: SelectGroupProps<T>) {
  return (
    <ListBoxSectionPrimitive
      data-slot="select-group"
      className={cn("scroll-my-1 p-1", className)}
      {...props}
    />
  )
}

function SelectValue<T extends object>({
  className,
  children,
  ...props
}: SelectValueProps<T>) {
  return (
    <SelectValuePrimitive
      data-slot="select-value"
      className={cn("flex flex-1 text-left data-placeholder:text-muted-foreground", className)}
      {...props}
    >
      {typeof children === "function"
        ? children
        : ({ selectedItems, selectedText, defaultChildren }) =>
            selectedItems.length > 1 ? selectedText : defaultChildren}
    </SelectValuePrimitive>
  )
}

function SelectTrigger({
  className,
  size = "default",
  children,
  ...props
}: Omit<React.ComponentProps<typeof ButtonPrimitive>, "children"> & {
  children?: React.ReactNode
  size?: "sm" | "default"
}) {
  return (
    <ButtonPrimitive
      data-slot="select-trigger"
      data-size={size}
      className={cn(
        "border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-full items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
        className
      )}
      {...props}
    >
      {children}
      <HugeiconsIcon icon={UnfoldMoreIcon} strokeWidth={2}
        className="text-muted-foreground size-4 pointer-events-none"
      />
    </ButtonPrimitive>
  )
}

function SelectContent({
  className,
  children,
  placement = "bottom",
  offset = 4,
  crossOffset = 0,
  ...props
}: Omit<
  React.ComponentProps<typeof PopoverPrimitive>,
  "className" | "children"
> & {
  className?: string
  children?: React.ReactNode
}) {
  return (
    <SelectPopover
      className={className}
      placement={placement}
      offset={offset}
      crossOffset={crossOffset}
      {...props}
    >
      <SelectList>{children}</SelectList>
    </SelectPopover>
  )
}

function SelectPopover({
  className,
  children,
  placement = "bottom start",
  offset = 4,
  crossOffset = 0,
  ...props
}: Omit<
  React.ComponentProps<typeof PopoverPrimitive>,
  "className" | "children"
> & {
  className?: string
  children?: React.ReactNode
}) {
  return (
    <PopoverPrimitive
      data-slot="select-content"
      placement={placement}
      offset={offset}
      crossOffset={crossOffset}
      className={cn(
        "bg-popover text-popover-foreground ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground! **:data-[slot$=-item]:data-focused:bg-foreground/10 relative isolate z-50 w-(--trigger-width) origin-(--trigger-anchor-point) overflow-hidden",
        className
      )}
      {...props}
    >
      {children}
    </PopoverPrimitive>
  )
}

function SelectList<T extends object>({
  className,
  ...props
}: ListBoxProps<T>) {
  return (
    <ListBoxPrimitive
      data-slot="select-list"
      className={cn(
        "group/select-list max-h-[inherit] overflow-x-hidden overflow-y-auto p-0 outline-hidden",
        className
      )}
      {...props}
    />
  )
}

function SelectInput({ className, ...props }: SearchFieldProps) {
  return (
    <SearchField
      {...props}
      autoFocus
      data-slot="select-input-wrapper"
      className={cn("p-1 pb-0", className)}
    >
      <InputGroup>
        <InputGroupInput
          data-slot="select-input"
          className="[&::-webkit-search-cancel-button]:hidden"
        />
        <InputGroupAddon>
          <HugeiconsIcon icon={SearchIcon} strokeWidth={2}
            className="size-4 shrink-0 opacity-50"
          />
        </InputGroupAddon>
      </InputGroup>
    </SearchField>
  )
}

function SelectLabel({
  className,
  ...props
}: React.ComponentProps<typeof HeaderPrimitive>) {
  return (
    <HeaderPrimitive
      data-slot="select-label"
      className={cn("text-muted-foreground px-1.5 py-1 text-xs", className)}
      {...props}
    />
  )
}

function SelectItem({
  className,
  children,
  ...props
}: React.ComponentProps<typeof ListBoxItemPrimitive>) {
  return (
    <ListBoxItemPrimitive
      data-slot="select-item"
      textValue={typeof children === "string" ? children : undefined}
      className={cn(
        "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-focused:bg-accent data-focused:text-accent-foreground relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
        className
      )}
      {...props}
    >
      {composeRenderProps(children, (children) => (
        <>
          <span className="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
            {children}
          </span>
          <SelectionIndicator className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
            <HugeiconsIcon icon={Tick02Icon} strokeWidth={2}
              className="pointer-events-none"
            />
          </SelectionIndicator>
        </>
      ))}
    </ListBoxItemPrimitive>
  )
}

function SelectSeparator({
  className,
  ...props
}: React.ComponentProps<typeof SeparatorPrimitive>) {
  return (
    <SeparatorPrimitive
      data-slot="select-separator"
      className={cn("bg-border -mx-1 my-1 h-px pointer-events-none", className)}
      {...props}
    />
  )
}

function SelectEmpty({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="select-empty"
      className={cn("text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/select-list:flex", className)}
      {...props}
    />
  )
}

export {
  Select,
  SelectContent,
  SelectGroup,
  SelectInput,
  SelectItem,
  SelectLabel,
  SelectList,
  SelectPopover,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
  SelectEmpty,
}

const items = [
  { label: "Apple", value: "apple" },
  { label: "Banana", value: "banana" },
  { label: "Blueberry", value: "blueberry" },
  { label: "Grapes", value: "grapes" },
  { label: "Pineapple", value: "pineapple" },
]

function toPlacement(
  side: "top" | "right" | "bottom" | "left",
  align: "start" | "center" | "end"
) {
  if (align === "center") return side
  if (side === "top" || side === "bottom") return `${side} ${align}` as const
  return `${side} ${align === "start" ? "top" : "bottom"}` as const
}

export default function SelectDemo({
  side = "bottom",
  sideOffset = 4,
  align = "center",
  alignOffset = 0,
  checkAnimation = "draw",
}: {
  side?: "top" | "right" | "bottom" | "left"
  sideOffset?: number
  align?: "start" | "center" | "end"
  alignOffset?: number
  checkAnimation?: "draw" | "pop"
}) {
  return (
    <AnimateUIProvider checkAnimation={checkAnimation}>
      <Select placeholder="Select a fruit" className="w-full max-w-48" aria-label="Fruit">
        <SelectTrigger>
          <SelectValue />
        </SelectTrigger>
        <SelectContent placement={toPlacement(side, align)} offset={sideOffset} crossOffset={alignOffset}>
          <SelectGroup>
            <SelectLabel>Fruits</SelectLabel>
            {items.map((item) => (
              <SelectItem key={item.value} id={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectGroup>
        </SelectContent>
      </Select>
    </AnimateUIProvider>
  )
}

Installation

Install the packages

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Add the shadcn/ui component

npx shadcn@latest add select
pnpm dlx shadcn@latest add select
yarn dlx shadcn@latest add select
bun x shadcn@latest add select

Animate it

The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate select
pnpm dlx @animate-ui/cli@latest animate select
yarn dlx @animate-ui/cli@latest animate select
bun x @animate-ui/cli@latest animate select

Usage

Import from Animate UI instead of react-aria-components: it re-exports all of React Aria, with an animated Popover, ListBox, ListBoxItem and SelectionIndicator. Everything else stays the same.

import {
  Button,
  ListBox,
  ListBoxItem,
  Popover,
  Select,
  SelectionIndicator,
  SelectValue,
} from '@animate-ui/react/primitives/react-aria';

export function FontPicker() {
  return (
    <Select aria-label="Font" defaultValue="geist">
      <Button>
        <SelectValue />
      </Button>
      <Popover placement="bottom start" offset={4}>
        <ListBox>
          <ListBoxItem id="inter" textValue="Inter">
            Inter
            <SelectionIndicator>✓</SelectionIndicator>
          </ListBoxItem>
          <ListBoxItem id="geist" textValue="Geist">
            Geist
            <SelectionIndicator>✓</SelectionIndicator>
          </ListBoxItem>
          <ListBoxItem id="lora" textValue="Lora">
            Lora
            <SelectionIndicator>✓</SelectionIndicator>
          </ListBoxItem>
        </ListBox>
      </Popover>
    </Select>
  );
}

Check animation

The chosen item's check draws itself while the previous one erases itself. With animation="pop" on every item's SelectionIndicator, the check glides from the previous item to the chosen one instead (a first choice pops it in from half its size):

<ListBoxItem id="geist" textValue="Geist">
  Geist
  <SelectionIndicator animation="pop">✓</SelectionIndicator>
</ListBoxItem>

The same demo with checkAnimation="pop" (its Controls switch it back to draw):

To pop every check of your app at once (checkboxes, menus, selects, comboboxes), set checkAnimation="pop" on an AnimateUIProvider instead.

Animation

React Aria's Select has no Animate UI v1 counterpart: its list opens in Animate UI's Popover, which moves like v1's Dropdown Menu content under a select.

  • Popover: the list fades and scales in from 0.9, from the trigger (--trigger-anchor-point), in 220ms, and closes the same way before it unmounts. Reopening while it closes picks up from where it is.
  • ListBoxItem: one highlight glides behind the focused item (the one React Aria marks data-focused) on a spring (stiffness 500, damping 40), following the pointer and the arrow keys alike, like v1's menu highlight and the Radix UI and Base UI selects. React Aria focuses the selected item when the list opens, so the highlight starts on it. Moving to another item, it carries on from where it is, at its speed: however fast the pointer or the arrow keys go, there's one highlight on screen. It only fades in (200ms, on CSS's ease) when it appears from nothing, and its color changes the same way when the item's highlighted background differs from the previous one's. It glides on the compositor (a Web Animation of transform), so a busy main thread, a heavy render for instance, doesn't stall it. When the pointer leaves the list, it stays on the last item. It wears the item's own focused background (data-focused:bg-accent…), read from your styles: the item's background is cleared while it's focused; its text colors stay. The highlight is rendered through the item's render prop: your own render still works. The ListBox is its own stacking context (isolation: isolate), so the highlight goes under the items' content, above the list's background.
  • SelectionIndicator (the check inside an item): the chosen item's check draws itself like a Checkbox's check (its stroke and opacity in 0.3s on Motion's easeOut, at once) while the previous one erases itself, and the list holds still for 250ms before it closes, so the change is seen nearly done before the list fades out. With animation="pop" (or checkAnimation="pop" on an AnimateUIProvider), the check glides there instead, on the highlight's spring (stiffness 500, damping 40), and the list holds for 150ms, and a first choice pops it in from half its size out of a 2px blur (a spring, stiffness 500, damping 24). The demo's Controls switch between the two. Choosing the item already chosen, and closing any other way (Escape, a click outside), don't wait. With selectionMode="multiple", the list stays open: a selected item's check draws itself, and erases itself when it's deselected; with pop, it pops in from half its size and shrinks back out in 0.15s. Only a change of selection animates the checks: opening the list shows them at rest. shadcn/ui renders the check as isSelected ? <Icon /> : null, which can't animate its leaving: render a SelectionIndicator on every item instead, as the demo does (a check rendered only on the selected item goes at once, though with pop it still glides). SelectValue renders the chosen item's content again in the trigger: the indicator renders nothing there (React Aria's own would throw outside the list). Outside Animate UI's ListBox and Menu (in a Tab, a ToggleButton…) or with your own render, it's React Aria's own.
  • Opening from the keyboard animates the same way.
  • Open from the start (defaultOpen), the list plays its entrance on page load.
  • Under reduced motion, the list only fades and doesn't hold; the highlight jumps from item to item instead of gliding (it still fades in when it appears); the checks only fade (a single selection's doesn't glide).

API Reference

Select, SelectValue, ListBoxSection, Header and the other components take React Aria props only. They are not animated.

Popover

React Aria props, plus:

PropTypeDefault
transition?
Transition{ duration: 0.22, ease: [0.215, 0.61, 0.355, 1] }
exitTransition?
Transitiontransition

ListBox

React Aria props only. It holds its items' highlight and its checks (a single selection's glides between them with pop).

ListBoxItem

React Aria props only. It takes part in the highlight; it needs nothing more.

SelectionIndicator

React Aria props, plus:

PropTypeDefault
animation?
'draw' | 'pop'the closest AnimateUIProvider's checkAnimation, else 'draw'
transition?
Transitiondraw: 0.3s on Motion's easeOut both ways (a Checkbox's, without its delay); pop: a spring (stiffness 500, damping 24) in, 0.15s out, and the highlight's spring (stiffness 500, damping 40) for the glide

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/18/2026

On this page