FewTone UI
ComponentsDocs

Getting started

  • Introduction
  • Installation
  • Theming

Components4

  • ButtonNew
  • InputNew
  • SelectNew
  • SwitchNew
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Select

SelectNew

pnpm dlx shadcn@latest add …/r/select.json

An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.

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

export function PlanPicker() {
  return (
    <Select defaultValue="pro">
      <SelectTrigger aria-label="Plan">
        <SelectValue placeholder="Choose a plan" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="free">Free</SelectItem>
        <SelectItem value="pro">Pro</SelectItem>
      </SelectContent>
    </Select>
  )
}
default.tsx
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { useId } from "react";

export default function SelectDefault() {
  const id = useId();
  return (
    <div className="grid w-full max-w-56 gap-2">
      <label htmlFor={id} className="text-sm font-medium">
        Plan
      </label>
      <Select defaultValue="pro">
        <SelectTrigger id={id}>
          <SelectValue placeholder="Choose a plan" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="free">Free</SelectItem>
          <SelectItem value="pro">Pro</SelectItem>
          <SelectItem value="team">Team</SelectItem>
          <SelectItem value="enterprise">Enterprise</SelectItem>
        </SelectContent>
      </Select>
    </div>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add lucide-react motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-popover: 1400;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --scale-press: 0.97;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      @keyframes popover-in {
        from {
          opacity: 0;
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          transform: scale(1);
        }
        to {
          opacity: 0;
          transform: scale(0.96);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
  4. 4

    Copy the source into your project.

    components/ui/select.tsx
    "use client";
    
    import { spring } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
    import { LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { Select as SelectPrimitive } from "radix-ui";
    import { type ComponentProps, createContext, type ReactNode, useContext, useId, useState } from "react";
    
    /*
     * Select, built on Radix Select (a select-only combobox: arrow keys, Home/End, typeahead, Escape).
     * Opening and closing are CSS keyframes from the trigger's side; the highlight is a pill that glides
     * between options. Radix moves focus on hover and on arrow keys, so the pill follows both.
     */
    
    function Select(props: ComponentProps<typeof SelectPrimitive.Root>) {
      return <SelectPrimitive.Root data-slot="select" {...props} />;
    }
    
    function SelectGroup(props: ComponentProps<typeof SelectPrimitive.Group>) {
      return <SelectPrimitive.Group data-slot="select-group" {...props} />;
    }
    
    function SelectValue(props: ComponentProps<typeof SelectPrimitive.Value>) {
      return <SelectPrimitive.Value data-slot="select-value" {...props} />;
    }
    
    const triggerSizes = {
      sm: "h-(--size-control-sm) rounded-md px-2.5",
      md: "h-(--size-control-md) px-3",
    } as const;
    
    function SelectTrigger({
      className,
      size = "md",
      children,
      ...props
    }: ComponentProps<typeof SelectPrimitive.Trigger> & { size?: keyof typeof triggerSizes }) {
      return (
        <SelectPrimitive.Trigger
          data-slot="select-trigger"
          data-size={size}
          className={cn(
            "group flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-input bg-transparent text-sm whitespace-nowrap shadow-xs",
            "transition duration-(--duration-fast) ease-out",
            "data-placeholder:text-muted-foreground *:data-[slot=select-value]:truncate",
            "outline-ring focus-visible:border-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
            "aria-invalid:border-destructive aria-invalid:outline-destructive",
            "disabled:cursor-not-allowed disabled:opacity-(--opacity-disabled)",
            triggerSizes[size],
            className,
          )}
          {...props}
        >
          {children}
          <SelectPrimitive.Icon asChild>
            <ChevronDownIcon className="size-(--size-icon-md) shrink-0 text-muted-foreground transition-transform duration-(--duration-base) ease-out group-data-[state=open]:rotate-180" />
          </SelectPrimitive.Icon>
        </SelectPrimitive.Trigger>
      );
    }
    
    /** Which option the gliding pill sits on. It lives inside the content, so every opening starts fresh. */
    const HighlightContext = createContext<{
      active: string | null;
      setActive: (value: string | null) => void;
      reduceMotion: boolean;
    } | null>(null);
    
    function SelectViewport({ children }: { children: ReactNode }) {
      const [active, setActive] = useState<string | null>(null);
      const groupId = useId();
      const reduceMotion = useReducedMotion() ?? false;
      return (
        <HighlightContext.Provider value={{ active, setActive, reduceMotion }}>
          <SelectPrimitive.Viewport asChild>
            {/* layoutScroll keeps the pill in place while the list scrolls */}
            <motion.div
              layoutScroll
              data-slot="select-viewport"
              className="w-full min-w-(--radix-select-trigger-width) scroll-my-1 p-1"
            >
              <LayoutGroup id={groupId}>{children}</LayoutGroup>
            </motion.div>
          </SelectPrimitive.Viewport>
        </HighlightContext.Provider>
      );
    }
    
    function SelectContent({
      className,
      children,
      position = "popper",
      sideOffset = 4,
      ...props
    }: ComponentProps<typeof SelectPrimitive.Content>) {
      return (
        <SelectPrimitive.Portal>
          <SelectPrimitive.Content
            data-slot="select-content"
            position={position}
            sideOffset={sideOffset}
            className={cn(
              "relative isolate z-(--z-popover) max-h-(--radix-select-content-available-height) min-w-(--radix-select-trigger-width) overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md",
              "origin-(--radix-select-content-transform-origin) data-[state=open]:animate-popover-in data-[state=closed]:animate-popover-out",
              className,
            )}
            {...props}
          >
            <SelectScrollUpButton />
            <SelectViewport>{children}</SelectViewport>
            <SelectScrollDownButton />
          </SelectPrimitive.Content>
        </SelectPrimitive.Portal>
      );
    }
    
    function SelectLabel({ className, ...props }: ComponentProps<typeof SelectPrimitive.Label>) {
      return (
        <SelectPrimitive.Label
          data-slot="select-label"
          className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
          {...props}
        />
      );
    }
    
    function SelectItem({
      className,
      children,
      value,
      onFocus,
      onBlur,
      ...props
    }: ComponentProps<typeof SelectPrimitive.Item>) {
      const highlight = useContext(HighlightContext);
      const active = highlight?.active === value;
      return (
        <SelectPrimitive.Item
          data-slot="select-item"
          value={value}
          onFocus={(event) => {
            highlight?.setActive(value);
            onFocus?.(event);
          }}
          onBlur={(event) => {
            // Moving to another option keeps the pill (it glides); leaving the list clears it
            const next = event.relatedTarget;
            if (!(next instanceof HTMLElement && next.closest("[data-slot=select-item]")))
              highlight?.setActive(null);
            onBlur?.(event);
          }}
          className={cn(
            "relative flex w-full cursor-default items-center gap-2 rounded-md py-1.5 ps-2 pe-8 text-sm outline-hidden select-none",
            "data-highlighted:text-accent-foreground",
            "data-disabled:pointer-events-none data-disabled:opacity-(--opacity-disabled)",
            "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--size-icon-md)",
            className,
          )}
          {...props}
        >
          {/* Options are not stacking contexts: the pill joins the list's own layer (the content is `isolate`),
              so it passes behind every option's text, whichever way it glides */}
          {active ? (
            <motion.span
              layoutId="select-highlight"
              aria-hidden
              className="absolute inset-0 -z-10 rounded-md bg-accent"
              transition={highlight?.reduceMotion ? { duration: 0 } : spring.snappy}
            />
          ) : null}
          <span className="absolute end-2 flex size-(--size-icon-md) items-center justify-center">
            <SelectPrimitive.ItemIndicator>
              <CheckIcon className="size-(--size-icon-sm)" />
            </SelectPrimitive.ItemIndicator>
          </span>
          <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
        </SelectPrimitive.Item>
      );
    }
    
    function SelectSeparator({ className, ...props }: ComponentProps<typeof SelectPrimitive.Separator>) {
      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
    }: ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
      return (
        <SelectPrimitive.ScrollUpButton
          data-slot="select-scroll-up-button"
          className={cn("flex cursor-default items-center justify-center py-1 text-muted-foreground", className)}
          {...props}
        >
          <ChevronUpIcon className="size-(--size-icon-sm)" />
        </SelectPrimitive.ScrollUpButton>
      );
    }
    
    function SelectScrollDownButton({
      className,
      ...props
    }: ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
      return (
        <SelectPrimitive.ScrollDownButton
          data-slot="select-scroll-down-button"
          className={cn("flex cursor-default items-center justify-center py-1 text-muted-foreground", className)}
          {...props}
        >
          <ChevronDownIcon className="size-(--size-icon-sm)" />
        </SelectPrimitive.ScrollDownButton>
      );
    }
    
    export {
      Select,
      SelectContent,
      SelectGroup,
      SelectItem,
      SelectLabel,
      SelectScrollDownButton,
      SelectScrollUpButton,
      SelectSeparator,
      SelectTrigger,
      SelectValue,
    };
  5. 5

    Update the import paths to match your project.

Examples

Groups

Group related options under a label, with a separator between groups. Options can be disabled.

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

export default function SelectGroups() {
  return (
    <Select>
      <SelectTrigger className="max-w-56" aria-label="Font">
        <SelectValue placeholder="Choose a font" />
      </SelectTrigger>
      <SelectContent>
        <SelectGroup>
          <SelectLabel>Sans serif</SelectLabel>
          <SelectItem value="geist">Geist</SelectItem>
          <SelectItem value="inter">Inter</SelectItem>
        </SelectGroup>
        <SelectSeparator />
        <SelectGroup>
          <SelectLabel>Monospace</SelectLabel>
          <SelectItem value="geist-mono">Geist Mono</SelectItem>
          <SelectItem value="jetbrains-mono">JetBrains Mono</SelectItem>
          <SelectItem value="courier" disabled>
            Courier (not installed)
          </SelectItem>
        </SelectGroup>
      </SelectContent>
    </Select>
  );
}

Long lists

Long lists scroll, with arrows at the ends. Type a few letters to jump to an option.

scrollable.tsx
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

const timezones = [
  "Pacific/Honolulu",
  "America/Los_Angeles",
  "America/Denver",
  "America/Chicago",
  "America/New_York",
  "America/Sao_Paulo",
  "Europe/London",
  "Europe/Berlin",
  "Africa/Cairo",
  "Europe/Moscow",
  "Asia/Dubai",
  "Asia/Kolkata",
  "Asia/Singapore",
  "Asia/Tokyo",
  "Australia/Sydney",
  "Pacific/Auckland",
];

export default function SelectScrollable() {
  return (
    <Select defaultValue="Asia/Kolkata">
      <SelectTrigger className="max-w-64" aria-label="Time zone">
        <SelectValue />
      </SelectTrigger>
      <SelectContent className="max-h-64">
        {timezones.map((zone) => (
          <SelectItem key={zone} value={zone}>
            {zone.replace("_", " ")}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

Sizes

The trigger comes in Button's small and medium heights.

sizes.tsx
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

const options = ["Daily", "Weekly", "Monthly"];

export default function SelectSizes() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      <Select defaultValue="Weekly">
        <SelectTrigger size="sm" className="w-32" aria-label="Frequency, small">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {options.map((option) => (
            <SelectItem key={option} value={option}>
              {option}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
      <Select defaultValue="Weekly">
        <SelectTrigger className="w-32" aria-label="Frequency, medium">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {options.map((option) => (
            <SelectItem key={option} value={option}>
              {option}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  );
}

API reference

Select

The root. Also accepts every Radix Select Root prop.

PropTypeDefaultDescription
value?string–The selected value, when you control it.
defaultValue?string–The initially selected value.
onValueChange?(value: string) => void–Called when the user picks an option.
name?string–Submits the value with a form.
disabled?booleanfalsePrevents opening.

SelectTrigger

The button that opens the list. Give it a label with aria-label or a <label htmlFor>.

PropTypeDefaultDescription
size?"sm" | "md""md"The height.
aria-invalid?boolean–Marks the value as wrong.

SelectContent

The list. Opens below the trigger and flips when there is no room.

PropTypeDefaultDescription
position?"popper" | "item-aligned""popper"Popper sits below the trigger; item-aligned puts the selected option over it, like a native select.
sideOffset?number4Gap between the trigger and the list, in px.

SelectItem

PropTypeDefaultDescription
valuestring–The value this option selects.
disabled?booleanfalseSkips the option.

Related components

  • InputNew

    An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.

  • SwitchNew

    An accessible React switch for on/off settings, with a spring-animated thumb, built on Radix Switch.

  • ButtonNew

    An accessible React button with six variants, three sizes, icon support and a spring press, built on a native button.

Last updated 13 Oct 2026Edit on GitHub