Styled select
Five selects: simple, glass, icon, pill and grouped. Each is self-contained (no radix, no native select), sized, token-driven, and opens on click with a listbox of options.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/select-styledDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/select-styled.tsx"use client"
import * as React from "react"
import { Check, ChevronDown, CreditCard, Settings, Star, User } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Select family: 5 decorative self-contained single-selects. NO radix, NO native
<select>, NO portal. Each export is a complete select: a relative inline-flex
wrapper + a real trigger button (the selected value + a chevron) + an options
panel absolutely positioned BELOW the trigger (top-full mt-1). The trigger opens
on CLICK; picking an option updates the value and closes the panel. It closes on
an outside click (window pointerdown, with inner clicks ignored via the wrapper
ref) and on Escape. Controlled (value/onValueChange) + uncontrolled
(defaultValue). AnimatePresence fade+scale; under reduced motion only a fade
(instant). Color comes ONLY from tokens, via alpha color-mix. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
/* Trigger yuksekligi base kontrol olcegiyle hizali (sm h-8, md h-9, lg h-10, xl h-12). */
const triggerHeight: Record<StyledSize, string> = {
sm: "h-8 text-sm",
md: "h-9 text-sm",
lg: "h-10 text-base",
xl: "h-12 text-base",
}
/* Panel genisligi trigger olcegine gore. */
const panelWidth: Record<StyledSize, string> = {
sm: "w-48",
md: "w-56",
lg: "w-64",
xl: "w-72",
}
export interface StyledSelectOption {
value: string
label: React.ReactNode
icon?: React.ReactNode
}
export interface StyledSelectGroup {
heading: React.ReactNode
options: StyledSelectOption[]
}
interface SelectProps {
className?: string
size?: StyledSize
placeholder?: string
options?: StyledSelectOption[]
value?: string
defaultValue?: string
onValueChange?: (v: string) => void
}
const defaultOptions: StyledSelectOption[] = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
{ value: "auto", label: "Automatic" },
]
const triggerBase =
"inline-flex w-full shrink-0 select-none items-center justify-between gap-2 whitespace-nowrap rounded-lg px-3 font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const panelBase =
"absolute left-0 top-full z-50 mt-1 max-h-72 origin-top overflow-auto rounded-xl border border-border bg-popover p-1.5 text-sm text-popover-foreground shadow-lg outline-none"
const optionBase =
"flex w-full cursor-default select-none items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm text-popover-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const panelMotion = {
initial: { opacity: 0, scale: 0.96, y: -6 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.96, y: -6 },
transition: { type: "spring" as const, stiffness: 340, damping: 26 },
}
/* Internal open/closed state: it closes on an outside click + Escape. */
function usePanelState() {
const [open, setOpen] = React.useState(false)
const wrapperRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false)
}
const onPointer = (e: PointerEvent) => {
const node = wrapperRef.current
if (node && e.target instanceof Node && !node.contains(e.target)) {
setOpen(false)
}
}
window.addEventListener("keydown", onKey)
window.addEventListener("pointerdown", onPointer)
return () => {
window.removeEventListener("keydown", onKey)
window.removeEventListener("pointerdown", onPointer)
}
}, [open])
return { open, setOpen, wrapperRef }
}
/* Controlled and uncontrolled value management. */
function useSelectValue(
value: string | undefined,
defaultValue: string | undefined,
onValueChange: ((v: string) => void) | undefined,
) {
const [internal, setInternal] = React.useState<string | undefined>(defaultValue)
const isControlled = value !== undefined
const current = isControlled ? value : internal
const select = React.useCallback(
(next: string) => {
if (!isControlled) setInternal(next)
onValueChange?.(next)
},
[isControlled, onValueChange],
)
return { current, select }
}
function flattenOptions(
options: StyledSelectOption[] | undefined,
groups?: StyledSelectGroup[],
): StyledSelectOption[] {
if (groups) return groups.flatMap((g) => g.options)
return options ?? defaultOptions
}
/* Shared shell: a relative wrapper plus a trigger button plus an AnimatePresence options panel. */
function SelectShell({
size = "md",
className,
open,
setOpen,
wrapperRef,
triggerClassName,
panelClassName,
triggerContent,
children,
}: {
size?: StyledSize
className?: string
open: boolean
setOpen: (v: boolean) => void
wrapperRef: React.RefObject<HTMLDivElement | null>
triggerClassName: string
panelClassName?: string
triggerContent: React.ReactNode
children: React.ReactNode
}) {
const reduce = useReducedMotion()
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
const active = reduce ? fade : panelMotion
return (
<div
ref={wrapperRef}
data-slot="styled-select"
className={cn("relative inline-flex w-56 max-w-full", panelWidth[size], className)}
>
<button
type="button"
data-slot="styled-select-trigger"
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
className={cn(triggerBase, triggerHeight[size], triggerClassName)}
onClick={() => setOpen(!open)}
>
{triggerContent}
<ChevronDown
className={cn(
"shrink-0 opacity-70 transition-transform duration-200",
open && "rotate-180",
)}
/>
</button>
<AnimatePresence>
{open ? (
<motion.div
role="listbox"
data-slot="styled-select-content"
className={cn(panelBase, panelWidth[size], panelClassName)}
initial={active.initial}
animate={active.animate}
exit={active.exit}
transition={reduce ? { duration: 0.16 } : panelMotion.transition}
>
{children}
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
/* Finds the label of the selected option (to show it in the trigger). */
function labelFor(options: StyledSelectOption[], current: string | undefined) {
if (current === undefined) return undefined
return options.find((o) => o.value === current)?.label
}
/* Simple: sade cerceveli trigger + liste. */
export function SimpleSelect({
className,
size = "md",
placeholder = "Select...",
options,
value,
defaultValue,
onValueChange,
}: SelectProps) {
const list = options ?? defaultOptions
const { open, setOpen, wrapperRef } = usePanelState()
const { current, select } = useSelectValue(value, defaultValue, onValueChange)
const selectedLabel = labelFor(list, current)
return (
<SelectShell
size={size}
className={className}
open={open}
setOpen={setOpen}
wrapperRef={wrapperRef}
triggerClassName="border border-field-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
triggerContent={
<span className={cn("truncate", selectedLabel === undefined && "text-muted-foreground")}>
{selectedLabel ?? placeholder}
</span>
}
>
<div className="flex flex-col">
{list.map((option) => {
const isSelected = option.value === current
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={isSelected}
tabIndex={0}
className={cn(optionBase)}
onClick={() => {
select(option.value)
setOpen(false)
}}
>
<span className="flex size-4 shrink-0 items-center justify-center text-primary">
{isSelected ? <Check /> : null}
</span>
<span className="truncate">{option.label}</span>
</button>
)
})}
</div>
</SelectShell>
)
}
/* Glass: buzlu cam trigger + panel (frosted). */
export function GlassSelect({
className,
size = "md",
placeholder = "Select...",
options,
value,
defaultValue,
onValueChange,
}: SelectProps) {
const list = options ?? defaultOptions
const { open, setOpen, wrapperRef } = usePanelState()
const { current, select } = useSelectValue(value, defaultValue, onValueChange)
const selectedLabel = labelFor(list, current)
return (
<SelectShell
size={size}
className={className}
open={open}
setOpen={setOpen}
wrapperRef={wrapperRef}
triggerClassName="border border-[color-mix(in_oklab,var(--color-border)_60%,transparent)] bg-[color-mix(in_oklab,var(--color-background)_55%,transparent)] text-foreground backdrop-blur-md hover:bg-[color-mix(in_oklab,var(--color-background)_70%,transparent)]"
panelClassName="border-[color-mix(in_oklab,var(--color-border)_60%,transparent)] bg-[color-mix(in_oklab,var(--color-popover)_70%,transparent)] backdrop-blur-xl"
triggerContent={
<span className={cn("truncate", selectedLabel === undefined && "text-muted-foreground")}>
{selectedLabel ?? placeholder}
</span>
}
>
<div className="flex flex-col">
{list.map((option) => {
const isSelected = option.value === current
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={isSelected}
tabIndex={0}
className={cn(optionBase)}
onClick={() => {
select(option.value)
setOpen(false)
}}
>
<span className="flex size-4 shrink-0 items-center justify-center text-primary">
{isSelected ? <Check /> : null}
</span>
<span className="truncate">{option.label}</span>
</button>
)
})}
</div>
</SelectShell>
)
}
const iconOptions: StyledSelectOption[] = [
{ value: "account", label: "Account", icon: <User /> },
{ value: "billing", label: "Billing", icon: <CreditCard /> },
{ value: "settings", label: "Settings", icon: <Settings /> },
{ value: "starred", label: "Starred", icon: <Star /> },
]
/* Icon: every option carries a leading token icon; the selected value shows its icon in the trigger too. */
export function IconSelect({
className,
size = "md",
placeholder = "Select...",
options,
value,
defaultValue,
onValueChange,
}: SelectProps) {
const list = options ?? iconOptions
const { open, setOpen, wrapperRef } = usePanelState()
const { current, select } = useSelectValue(value, defaultValue, onValueChange)
const selected = list.find((o) => o.value === current)
return (
<SelectShell
size={size}
className={className}
open={open}
setOpen={setOpen}
wrapperRef={wrapperRef}
triggerClassName="border border-field-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
triggerContent={
<span className="flex min-w-0 items-center gap-2">
{selected?.icon ? (
<span className="flex shrink-0 items-center text-muted-foreground">{selected.icon}</span>
) : null}
<span className={cn("truncate", selected === undefined && "text-muted-foreground")}>
{selected?.label ?? placeholder}
</span>
</span>
}
>
<div className="flex flex-col">
{list.map((option) => {
const isSelected = option.value === current
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={isSelected}
tabIndex={0}
className={cn(optionBase)}
onClick={() => {
select(option.value)
setOpen(false)
}}
>
{option.icon ? (
<span className="flex shrink-0 items-center text-muted-foreground">
{option.icon}
</span>
) : null}
<span className="flex-1 truncate">{option.label}</span>
{isSelected ? (
<Check className="shrink-0 text-primary" />
) : null}
</button>
)
})}
</div>
</SelectShell>
)
}
/* Pill: tamamen yuvarlak (rounded-full) pill trigger. */
export function PillSelect({
className,
size = "md",
placeholder = "Select...",
options,
value,
defaultValue,
onValueChange,
}: SelectProps) {
const list = options ?? defaultOptions
const { open, setOpen, wrapperRef } = usePanelState()
const { current, select } = useSelectValue(value, defaultValue, onValueChange)
const selectedLabel = labelFor(list, current)
return (
<SelectShell
size={size}
className={className}
open={open}
setOpen={setOpen}
wrapperRef={wrapperRef}
triggerClassName="rounded-full border border-field-border bg-secondary px-4 text-secondary-foreground hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)]"
panelClassName="rounded-2xl"
triggerContent={
<span className={cn("truncate", selectedLabel === undefined && "text-muted-foreground")}>
{selectedLabel ?? placeholder}
</span>
}
>
<div className="flex flex-col">
{list.map((option) => {
const isSelected = option.value === current
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={isSelected}
tabIndex={0}
className={cn(optionBase, "rounded-full")}
onClick={() => {
select(option.value)
setOpen(false)
}}
>
<span className="flex size-4 shrink-0 items-center justify-center text-primary">
{isSelected ? <Check /> : null}
</span>
<span className="truncate">{option.label}</span>
</button>
)
})}
</div>
</SelectShell>
)
}
const groupedData: StyledSelectGroup[] = [
{
heading: "Fruits",
options: [
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
{ value: "orange", label: "Orange" },
],
},
{
heading: "Vegetables",
options: [
{ value: "carrot", label: "Carrot" },
{ value: "spinach", label: "Spinach" },
],
},
]
interface GroupedProps extends Omit<SelectProps, "options"> {
groups?: StyledSelectGroup[]
}
/* Grouped: option'lar token label header'lar altinda gruplanir. */
export function GroupedSelect({
className,
size = "md",
placeholder = "Select...",
groups,
value,
defaultValue,
onValueChange,
}: GroupedProps) {
const data = groups ?? groupedData
const { open, setOpen, wrapperRef } = usePanelState()
const { current, select } = useSelectValue(value, defaultValue, onValueChange)
const flat = flattenOptions(undefined, data)
const selectedLabel = labelFor(flat, current)
return (
<SelectShell
size={size}
className={className}
open={open}
setOpen={setOpen}
wrapperRef={wrapperRef}
triggerClassName="border border-field-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
triggerContent={
<span className={cn("truncate", selectedLabel === undefined && "text-muted-foreground")}>
{selectedLabel ?? placeholder}
</span>
}
>
<div className="flex flex-col">
{data.map((group, gi) => (
<div key={`${gi}`} className="flex flex-col">
{gi > 0 ? (
<div
role="separator"
className="my-1.5 h-px bg-[color-mix(in_oklab,var(--color-border)_100%,transparent)]"
/>
) : null}
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{group.heading}
</div>
{group.options.map((option) => {
const isSelected = option.value === current
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={isSelected}
tabIndex={0}
className={cn(optionBase)}
onClick={() => {
select(option.value)
setOpen(false)
}}
>
<span className="flex size-4 shrink-0 items-center justify-center text-primary">
{isSelected ? <Check /> : null}
</span>
<span className="truncate">{option.label}</span>
</button>
)
})}
</div>
))}
</div>
</SelectShell>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Simple
A clean bordered trigger and list.
import { SimpleSelect } from "@/components/ui/select-styled"
<SimpleSelect />Glass
A frosted glass trigger and panel.
import { GlassSelect } from "@/components/ui/select-styled"
<GlassSelect />Icon
Options carry a leading token icon.
import { IconSelect } from "@/components/ui/select-styled"
<IconSelect />Pill
A fully rounded pill trigger.
import { PillSelect } from "@/components/ui/select-styled"
<PillSelect />Grouped
Options grouped under token headers.
import { GroupedSelect } from "@/components/ui/select-styled"
<GroupedSelect />ai2 Styled select: 5 styled variations on the token system
The ai2 Styled select are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around single-choice select menus. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion fades and scales the option panel in from the trigger. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the scale is skipped and the panel appears instantly.
What is in the ai2 Styled select?
5 exports in one file: Simple, Glass, Icon, Pill and Grouped. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion fades and scales the option panel in from the trigger.
- Reduced-motion aware: Under prefers-reduced-motion, the scale is skipped and the panel appears instantly.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Styled select sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.