a one-row choice of examples or languages, with icons and hints
npx shadcn add @ag/preview-pickerAlready installed? Preview an update
npx shadcn add @ag/preview-picker --dry-runYour 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.
the demo above, verbatim
"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
"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>
)
}