← ui

preview-picker

ui

a one-row choice of examples or languages, with icons and hints

npx shadcn add @ag/preview-picker
Already installed? Preview an update
npx shadcn add @ag/preview-picker --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • Changed: the pill stays whole; too many choices for it scroll sideways inside it (data-slot="preview-picker-options"), the hidden side fading.
Updated 2026-10-12 · release notes

shelf media

uses scrims, tooltip

used by card-gallery, theme-toggle, scroll-story

the demo above, verbatim

preview-picker.demo.tsx
"use client"

import { useState } from "react"
import { Sample } from "@/app/samples"
import { PreviewPicker } from "@/registry/base-nova/ui/preview-picker"

export default function Demo() {
  // #region state
  const [language, setLanguage] = useState("en")
  const [example, setExample] = useState(0)
  // #endregion
  return (
    <div className="space-y-10">
      <Sample name="languages" glance title="languages with icons" with="state">
        <PreviewPicker
          label="Preview language"
          value={language}
          onChange={setLanguage}
          options={[
            { value: "en", label: "English", icon: "🇬🇧" },
            { value: "es", label: "Español", icon: "🇪🇸" },
          ]}
        />
        <p className="mt-4 text-foreground/70" aria-live="polite">
          {language === "en"
            ? "A little more context."
            : "Un poco más de contexto."}
        </p>
      </Sample>
      <Sample name="examples" title="text and disabled choices" with="state">
        <PreviewPicker
          label="Preview example"
          value={example}
          onChange={setExample}
          options={[
            { value: 0, label: "overview" },
            { value: 1, label: "details" },
            { value: 2, label: "coming soon", disabled: true },
          ]}
        />
        <p className="mt-4 text-foreground/70" aria-live="polite">
          {example === 0
            ? "The whole picture."
            : "The small things that matter."}
        </p>
      </Sample>
    </div>
  )
}

the source, every file an install writes

preview-picker.tsx
"use client"

import { type ComponentPropsWithRef, type ReactNode, useId } from "react"
import { cn } from "@/lib/utils"
import { Tip } from "@/registry/base-nova/ui/tooltip"
import "./edge-fade"

export interface PreviewPickerProps<T extends string | number>
  extends Omit<ComponentPropsWithRef<"fieldset">, "onChange" | "children"> {
  label: string
  value: T
  options: readonly {
    value: T
    label: string
    icon?: ReactNode
    disabled?: boolean
    /** What this option means, shown on hover of the option itself (its label dotted
     *  underneath as the affordance) and read with it by a screen reader. */
    hint?: string
  }[]
  onChange: (value: T) => void
  className?: string
  size?: "default" | "compact"
  /** Keep each option's label accessible while showing only its icon. */
  iconsOnly?: boolean
}

/** A single-row selector; narrow containers scroll horizontally rather than wrap. */
export function PreviewPicker<T extends string | number>({
  label,
  value,
  options,
  onChange,
  className,
  size = "default",
  iconsOnly = false,
  name: providedName,
  ...props
}: PreviewPickerProps<T>) {
  const generatedName = useId()
  const name = providedName ?? generatedName
  return (
    <fieldset
      {...props}
      data-slot="preview-picker"
      aria-label={label}
      className={cn(
        "inline-flex min-w-0 max-w-full rounded-full bg-foreground/5 p-1",
        className,
      )}
    >
      {/* The pill stays whole; too many choices for it scroll sideways inside it,
          the hidden side fading (scrims' `ag-edge-fade`). */}
      <div
        data-slot="preview-picker-options"
        className="ag-edge-fade flex min-w-0 flex-nowrap gap-1 rounded-full"
      >
        {options.map((option) => {
          const choice = (
            <label
              data-slot="preview-picker-option"
              key={option.value}
              title={iconsOnly ? option.label : undefined}
              data-selected={value === option.value || undefined}
              data-disabled={option.disabled || undefined}
              className={cn(
                "relative flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium text-muted-foreground transition-colors hover:text-foreground data-selected:bg-foreground/10 data-selected:text-foreground has-focus-visible:focus-ring has-focus-visible:-outline-offset-(--focus-ring-offset) data-disabled:cursor-not-allowed data-disabled:opacity-40 motion-reduce:transition-none",
                size === "compact" ? "min-h-8 px-3 py-1" : "min-h-11 px-4 py-2",
                iconsOnly &&
                  (size === "compact" ? "size-8 p-0" : "size-11 p-0"),
              )}
            >
              <input
                type="radio"
                name={name}
                value={option.value}
                checked={value === option.value}
                disabled={option.disabled}
                onChange={() => onChange(option.value)}
                aria-description={option.hint}
                className="sr-only"
              />
              {option.icon != null && (
                <span
                  data-slot="preview-picker-icon"
                  aria-hidden
                  className="grid size-5 place-items-center text-xl leading-none [&>svg]:size-4"
                >
                  {option.icon}
                </span>
              )}
              {/* An option that explains itself says so: its label dotted underneath,
              the affordance for "there is more here on hover". */}
              <span
                className={cn(
                  iconsOnly && "sr-only",
                  option.hint &&
                    "underline decoration-current/35 decoration-dotted underline-offset-4",
                )}
              >
                {option.label}
              </span>
            </label>
          )
          return option.hint ? (
            <Tip key={option.value} content={option.hint}>
              {choice}
            </Tip>
          ) : (
            choice
          )
        })}
      </div>
    </fieldset>
  )
}