Beste UI Components
componentinspector-alignsource: ok · refreshed
Inspector Align
Where the contents of a box sit in it: nine cells drawn in visual terms, mapped onto justify and align afterwards so the pair swapping places under a column direction is the component's problem and not the reader's. Direction, distribution and stretch sit under the pad as rows of the family, since each is a setting like any other.
npx shadcn add "https://ui.beste.co/component/r/inspector-align.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightclsxtailwind-mergelucide-react
Registry dependencies
popoverselect
Files4 · source cached 10/8/2026, 8:48:13 AM
registry-components/inspector-align/inspector-align.tsx
"use client";
import { type LucideIcon, MoveHorizontalIcon, MoveVerticalIcon } from "lucide-react";
import * as React from "react";
import { InspectorSegmented } from "@/components/beste/component/inspector-segmented";
import { InspectorSelect } from "@/components/beste/component/inspector-select";
import { InspectorTabs } from "@/components/beste/component/inspector-tabs";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
/** Surface treatment of the row. Mirrors inspector-slider. */
type Tone = "muted" | "outline" | "ghost";
/** Row height preset. Mirrors inspector-slider. */
type Size = "sm" | "default" | "lg";
/** The row itself is the trigger, so it answers hover like any other in the family. */
const toneStyles: Record<Tone, string> = {
muted: "border border-transparent bg-muted hover:bg-muted-foreground/15",
outline: "border border-border hover:bg-muted",
ghost: "border border-transparent hover:border-border hover:bg-muted",
};
const sizeStyles: Record<Size, string> = {
sm: "[--inspector-height:--spacing(8)] [--inspector-pad:--spacing(2.5)]",
default: "[--inspector-height:--spacing(9)] [--inspector-pad:--spacing(3)]",
lg: "[--inspector-height:--spacing(11)] [--inspector-pad:--spacing(4)]",
};
/** Where things sit along an axis. */
type Place = "start" | "center" | "end";
/** How the spare room along the main axis is handed out. */
type Distribute = "packed" | "between" | "around" | "evenly";
type Direction = "row" | "column";
interface AlignValue {
/** Along the main axis — the one `direction` names. */
justify?: Place;
/** Across it. */
align?: Place | "stretch";
/** Which way the main axis runs. */
direction?: Direction;
/**
* What happens to the spare room on the main axis. `packed` leaves it where
* `justify` says; the rest ignore `justify`, because they are answers to the
* same question.
*/
distribute?: Distribute;
}
const PLACES: Place[] = ["start", "center", "end"];
const PLACE_WORDS: Record<Place | "stretch", string> = {
start: "Start",
center: "Center",
end: "End",
stretch: "Stretch",
};
/** Vertical placement reads as top/middle/bottom, which is what a reader says. */
const VERTICAL_WORDS: Record<Place, string> = {
start: "top",
center: "middle",
end: "bottom",
};
const DISTRIBUTE_WORDS: Record<Distribute, string> = {
packed: "Packed",
between: "Space between",
around: "Space around",
evenly: "Space evenly",
};
/** What each value is called in CSS, which is what `toCss` writes. */
const CSS_PLACE: Record<Place | "stretch", string> = {
start: "flex-start",
center: "center",
end: "flex-end",
stretch: "stretch",
};
const CSS_DISTRIBUTE: Record<Distribute, string> = {
packed: "",
between: "space-between",
around: "space-around",
evenly: "space-evenly",
};
/**
* The CSS the value stands for. Exported because a panel that sets alignment has
* to write it somewhere, and every consumer otherwise rebuilds this mapping — the
* one part of the component that is not about the picking.
*/
export function alignToCss(value: AlignValue): {
flexDirection: Direction;
justifyContent: string;
alignItems: string;
} {
const distribute = value.distribute ?? "packed";
return {
flexDirection: value.direction ?? "row",
justifyContent:
distribute === "packed"
? CSS_PLACE[value.justify ?? "start"]
: CSS_DISTRIBUTE[distribute],
alignItems: CSS_PLACE[value.align ?? "start"],
};
}
interface InspectorAlignProps {
/** Label rendered on the left, inside the row. */
label: string;
/** Optional leading icon shown before the label. */
icon?: LucideIcon;
/** Controlled value. Pair it with `onValueChange`. */
value?: AlignValue;
/** Initial value in uncontrolled mode. */
defaultValue?: AlignValue;
onValueChange?: (value: AlignValue) => void;
/** Fires when the editor opens or closes. */
onOpenChange?: (open: boolean) => void;
/**
* Offer the direction, which decides which axis `justify` is on.
* @defaultValue true */
directional?: boolean;
/**
* Offer space-between and its neighbours.
* @defaultValue true */
distributable?: boolean;
/**
* Offer `stretch` across the cross axis.
* @defaultValue true */
stretchable?: boolean;
/** Block interaction and dim the row. */
disabled?: boolean;
/**
* Surface treatment: filled (default), hairline outline, or bare until hover.
* @defaultValue "muted" */
tone?: Tone;
/**
* Row height preset.
* @defaultValue "default" */
size?: Size;
className?: string;
/** Accessible name. Falls back to `label`. */
"aria-label"?: string;
}
export const inspectorAlignDemo: InspectorAlignProps = {
label: "Align",
className: "w-72",
defaultValue: { justify: "center", align: "center", direction: "row", distribute: "packed" },
};
/**
* Where the contents of a box sit in it.
*
* The nine cells are the value, which is what makes this different from
* inspector-position: that one is a point on a picture and takes any pair of
* numbers, this one is a pair of keywords with three settings each, and a pad that
* snaps is the wrong shape for a value that was never continuous.
*
* The grid is drawn in **visual** terms — left/centre/right by top/middle/bottom —
* and mapped onto `justify` and `align` afterwards, because those two swap places
* the moment the direction is a column, and a reader picking a corner should not
* have to hold that in their head.
*/
export function InspectorAlign({
label,
icon: Icon,
value: valueProp,
defaultValue,
onValueChange,
onOpenChange,
directional = true,
distributable = true,
stretchable = true,
disabled = false,
tone = "muted",
size = "default",
className,
"aria-label": ariaLabel,
}: InspectorAlignProps) {
const [internalValue, setInternalValue] = React.useState<AlignValue>(
defaultValue ?? { justify: "start", align: "start", direction: "row", distribute: "packed" },
);
const value = valueProp ?? internalValue;
const [open, setOpen] = React.useState(false);
const direction = value.direction ?? "row";
const distribute = value.distribute ?? "packed";
const justify = value.justify ?? "start";
const align = value.align ?? "start";
const write = (patch: Partial<AlignValue>) => {
const next = { ...value, ...patch };
if (valueProp === undefined) setInternalValue(next);
onValueChange?.(next);
};
/*
* The two mappings between what is drawn and what is stored. `justify` is on the
* main axis, which is horizontal in a row and vertical in a column, so the grid's
* own axes have to be translated in both directions.
*/
const horizontal: Place = direction === "row" ? justify : placeOf(align);
const vertical: Place = direction === "row" ? placeOf(align) : justify;
const pick = (h: Place, v: Place) => {
const main = direction === "row" ? h : v;
const cross = direction === "row" ? v : h;
// Choosing a cell is choosing where things are packed, so it takes the
// distribution back off `space-between` rather than setting a value the CSS
// will then ignore.
write({ justify: main, align: cross, distribute: "packed" });
};
const summary =
distribute === "packed"
? `${PLACE_WORDS[direction === "row" ? justify : align]} ${
VERTICAL_WORDS[direction === "row" ? placeOf(align) : justify]
}`.trim()
: DISTRIBUTE_WORDS[distribute];
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
onOpenChange?.(next);
}}
>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
aria-label={ariaLabel}
data-slot="inspector-align"
data-disabled={disabled}
className={cn(
"group/inspector-align flex w-full cursor-pointer items-center gap-2 text-left",
"h-(--inspector-height) rounded-(--inspector-radius) px-(--inspector-pad)",
"[--inspector-radius:var(--radius-xl)] transition-colors",
"outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
sizeStyles[size],
toneStyles[tone],
className,
)}
>
<span
data-slot="inspector-align-label"
className="flex min-w-0 items-center gap-1.5 text-sm font-medium text-foreground/70 select-none"
>
{Icon ? <Icon className="size-4 shrink-0" /> : null}
<span className="truncate">{label}</span>
</span>
<span
data-slot="inspector-align-value"
className="ml-auto min-w-0 truncate text-sm font-medium text-foreground select-none"
>
{summary}
</span>
{/* The same picture the cells are drawn with, at row scale: three bars
sitting where the value puts them. */}
<span
aria-hidden="true"
data-slot="inspector-align-glyph"
className="flex size-5 shrink-0 items-center justify-center rounded-sm border border-border"
>
<Bars horizontal={horizontal} vertical={vertical} direction={direction} compact />
</span>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={8}
className="w-[var(--radix-popover-trigger-width)] min-w-64 p-2"
>
{/*
The pad on its own surface at the top, the way inspector-shadow gives its
preview one: it is the control the editor is for, and the settings under it
are qualifications of the choice made here.
*/}
<div className="mb-2 flex justify-center rounded-md bg-muted/50 py-3">
{/* A group, not a `grid`: the ARIA grid pattern wants rows and cells and
a keyboard that walks them, and these are nine buttons Tab already
reaches in the order they read. */}
{/* biome-ignore lint/a11y/useSemanticElements: a fieldset wants a legend, and the name for this set is the row that opened it */}
<div
role="group"
aria-label={`${ariaLabel ?? label} placement`}
className="grid grid-cols-3 gap-1"
>
{PLACES.map((v) =>
PLACES.map((h) => {
const isActive = distribute === "packed" && h === horizontal && v === vertical;
return (
<button
key={`${h}-${v}`}
type="button"
onClick={() => pick(h, v)}
aria-pressed={isActive}
aria-label={`${PLACE_WORDS[h]} ${VERTICAL_WORDS[v]}`}
title={`${PLACE_WORDS[h]} ${VERTICAL_WORDS[v]}`}
className={cn(
"flex size-10 cursor-pointer items-center justify-center rounded-md border",
"transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
isActive
? "border-foreground/20 bg-foreground/10"
: "border-border bg-background hover:bg-accent",
)}
>
<Bars horizontal={h} vertical={v} direction={direction} />
</button>
);
}),
)}
</div>
</div>
{/*
Pieces of the family rather than three hand-built label-and-buttons blocks,
which is what this was and what made it read as a stack of loose parts with
headings of their own.
Which piece each one is follows from the choice it holds: four options read
one at a time are a menu, two read together are a segmented row, and the one
that needs no name at all is a tab strip.
*/}
<div className="flex flex-col gap-1">
{/*
Direction carries no label, unlike the two rows under it. It is not a
setting the reader looks up by name — it says which way the pad above is
being read, so it belongs to the pad rather than to the list, and the two
words are their own label. A tab strip is the shape for that; a labelled
row would be claiming it is the first of three settings.
*/}
{directional ? (
<InspectorTabs
aria-label="Direction"
size="sm"
tabs={[
{ value: "row", label: "Row", icon: MoveHorizontalIcon },
{ value: "column", label: "Column", icon: MoveVerticalIcon },
]}
value={direction}
onValueChange={(next) => write({ direction: next as Direction })}
/>
) : null}
{distributable ? (
<InspectorSelect
label="Distribute"
size="sm"
options={(Object.keys(DISTRIBUTE_WORDS) as Distribute[]).map((entry) => ({
value: entry,
label: DISTRIBUTE_WORDS[entry],
}))}
value={distribute}
onValueChange={(next) => write({ distribute: next as Distribute })}
/>
) : null}
{stretchable ? (
<InspectorSegmented
label="Cross axis"
size="sm"
options={[
{ value: "place", label: "Place" },
{ value: "stretch", label: "Stretch" },
]}
value={align === "stretch" ? "stretch" : "place"}
onValueChange={(next) =>
// Coming back off stretch lands on the middle rather than on
// whatever it was before: the grid has to have a cell to light up,
// and nothing was stored while it was stretched.
write({ align: next === "stretch" ? "stretch" : "center" })
}
/>
) : null}
</div>
</PopoverContent>
</Popover>
);
}
/** `stretch` has no cell of its own, so the grid reads it as the middle. */
function placeOf(value: Place | "stretch"): Place {
return value === "stretch" ? "center" : value;
}
/**
* Three bars sitting where the value puts them. The preview is the same picture at
* two scales, which is what lets the row show the value without a second language
* for it.
*/
function Bars({
horizontal,
vertical,
direction,
compact = false,
}: {
horizontal: Place;
vertical: Place;
direction: Direction;
compact?: boolean;
}) {
const justifyClass: Record<Place, string> = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
};
const alignClass: Record<Place, string> = {
start: "items-start",
center: "items-center",
end: "items-end",
};
return (
<span
className={cn(
"flex size-full",
compact ? "gap-px p-0.5" : "gap-0.5 p-1.5",
direction === "row" ? "flex-row" : "flex-col",
direction === "row" ? justifyClass[horizontal] : justifyClass[vertical],
direction === "row" ? alignClass[vertical] : alignClass[horizontal],
)}
>
{[3, 5, 2].map((length, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: three fixed bars, and the list never changes
key={index}
className={cn(
"shrink-0 rounded-[1px] bg-foreground/70",
direction === "row" ? "w-0.5" : "h-0.5",
)}
style={
direction === "row"
? { height: compact ? length + 2 : length * 2 }
: { width: compact ? length + 2 : length * 2 }
}
/>
))}
</span>
);
}
registry-components/inspector-segmented/inspector-segmented.tsx
"use client";
import type { LucideIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
/** Surface treatment of the row. Mirrors inspector-slider. */
type Tone = "muted" | "outline" | "ghost";
/** Row height preset. Mirrors inspector-slider. */
type Size = "sm" | "default" | "lg";
const toneStyles: Record<Tone, string> = {
muted: "border border-transparent bg-muted",
outline: "border border-border",
ghost: "border border-transparent hover:border-border",
};
const sizeStyles: Record<Size, string> = {
sm: "[--inspector-height:--spacing(8)] [--inspector-pad:--spacing(2.5)]",
default: "[--inspector-height:--spacing(9)] [--inspector-pad:--spacing(3)]",
lg: "[--inspector-height:--spacing(11)] [--inspector-pad:--spacing(4)]",
};
interface InspectorSegmentedOption {
/** Value reported on selection. */
value: string;
/** Text on the segment. Falls back to `value`. */
label?: string;
/** Icon on the segment. Shown alone when there is no label. */
icon?: LucideIcon;
disabled?: boolean;
}
interface InspectorSegmentedProps {
/** Label rendered on the left, inside the row. */
label: string;
/** Optional leading icon shown before the label. */
icon?: LucideIcon;
/**
* The choices. Plain strings are enough when the value is the text. Two to four
* segments is the range this reads well at; past that reach for
* inspector-select.
*/
options?: (string | InspectorSegmentedOption)[];
/** Controlled value. Pair it with `onValueChange`. */
value?: string;
/** Initial value in uncontrolled mode. Falls back to the first option. */
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Block interaction and dim the row. */
disabled?: boolean;
/**
* Surface treatment: filled (default), hairline outline, or bare until hover.
* @defaultValue "muted" */
tone?: Tone;
/**
* Row height preset.
* @defaultValue "default" */
size?: Size;
/** Name shared by the underlying radios, so the row can take part in a form. */
name?: string;
className?: string;
/** Accessible name for the group. Falls back to `label`. */
"aria-label"?: string;
}
export const inspectorSegmentedDemo: InspectorSegmentedProps = {
label: "Dark Mode",
className: "w-72",
options: [
{ value: "off", label: "Off" },
{ value: "on", label: "On" },
],
defaultValue: "off",
};
export function InspectorSegmented({
label,
icon: Icon,
options = [],
value: valueProp,
defaultValue,
onValueChange,
disabled = false,
tone = "muted",
size = "default",
name,
className,
"aria-label": ariaLabel,
}: InspectorSegmentedProps) {
const items = React.useMemo(
() =>
options
.map((entry) => (typeof entry === "string" ? { value: entry } : entry))
.filter((option) => option.value),
[options],
);
const [internalValue, setInternalValue] = React.useState(
defaultValue ?? items[0]?.value ?? "",
);
const value = valueProp ?? internalValue;
const generatedName = React.useId();
const groupName = name ?? generatedName;
const selectedIndex = items.findIndex((option) => option.value === value);
const select = (next: string) => {
if (valueProp === undefined) setInternalValue(next);
onValueChange?.(next);
};
return (
<div
data-slot="inspector-segmented"
data-disabled={disabled}
className={cn(
"group/inspector-segmented flex items-center gap-2",
"h-(--inspector-height) rounded-(--inspector-radius) px-(--inspector-pad)",
"[--inspector-radius:var(--radius-xl)]",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
sizeStyles[size],
toneStyles[tone],
className,
)}
>
<span
data-slot="inspector-segmented-label"
className="flex min-w-0 items-center gap-1.5 text-sm font-medium text-foreground/70 select-none"
>
{Icon ? <Icon className="size-4 shrink-0" /> : null}
<span className="truncate">{label}</span>
</span>
{/*
Radios rather than buttons: keyboard selection with the arrow keys, the
group semantics and taking part in a form all come from the platform, and
each input is only hidden, never removed.
*/}
<div
role="radiogroup"
aria-label={ariaLabel ?? label}
data-slot="inspector-segmented-track"
className="relative ml-auto grid shrink-0 items-center"
style={{ gridTemplateColumns: `repeat(${Math.max(items.length, 1)}, minmax(0, 1fr))` }}
>
{/*
Equal-width segments mean the marker can be placed by index alone — no
measuring, and the slide runs on the compositor.
*/}
<span
aria-hidden="true"
data-slot="inspector-segmented-marker"
className={cn(
"pointer-events-none absolute inset-y-0 left-0 rounded-md bg-foreground/10",
"motion-safe:transition-transform motion-safe:duration-200",
selectedIndex < 0 && "opacity-0",
)}
style={{
width: `${100 / Math.max(items.length, 1)}%`,
transform: `translateX(${Math.max(selectedIndex, 0) * 100}%)`,
}}
/>
{items.map((option) => {
const OptionIcon = option.icon;
const isSelected = option.value === value;
const text = option.label ?? option.value;
return (
<label
key={option.value}
data-slot="inspector-segmented-item"
data-state={isSelected ? "on" : "off"}
className={cn(
"relative flex h-6 min-w-0 cursor-pointer items-center justify-center gap-1.5 px-2.5",
"rounded-md text-sm font-medium transition-colors select-none",
"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring/50",
isSelected ? "text-foreground" : "text-foreground/70 hover:text-foreground",
option.disabled && "pointer-events-none opacity-50",
)}
>
<input
type="radio"
name={groupName}
value={option.value}
checked={isSelected}
disabled={disabled || option.disabled}
onChange={() => select(option.value)}
className="sr-only"
/>
{OptionIcon ? (
<OptionIcon className="size-4 shrink-0" aria-label={option.label ? undefined : text} />
) : null}
{option.label || !OptionIcon ? <span className="truncate">{text}</span> : null}
</label>
);
})}
</div>
</div>
);
}
registry-components/inspector-select/inspector-select.tsx
"use client";
import type { LucideIcon } from "lucide-react";
import * as React from "react";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
/** Surface treatment of the row. Mirrors inspector-slider. */
type Tone = "muted" | "outline" | "ghost";
/** Row height preset. Mirrors inspector-slider. */
type Size = "sm" | "default" | "lg";
/*
* The hover and open surfaces are translucent tints rather than `bg-accent`,
* which is the same colour as `bg-muted` in a light theme and would read as no
* change at all. Tinting also blends when the row sits on a card.
*
* Every surface is declared twice, plain and `dark:`. That is not redundant: the
* shadcn select trigger ships its own `dark:bg-input/30` and
* `dark:hover:bg-input/50`, and a `dark:`-prefixed rule outranks an unprefixed
* one, so a plain `bg-muted` alone would lose in dark mode.
*/
const toneStyles: Record<Tone, string> = {
muted:
"border-transparent bg-muted dark:bg-muted hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/15 data-[state=open]:bg-muted-foreground/20 dark:data-[state=open]:bg-muted-foreground/20",
outline:
"border-border bg-transparent dark:bg-transparent hover:bg-muted dark:hover:bg-muted data-[state=open]:bg-muted dark:data-[state=open]:bg-muted",
ghost:
"border-transparent bg-transparent dark:bg-transparent hover:bg-muted dark:hover:bg-muted data-[state=open]:bg-muted dark:data-[state=open]:bg-muted",
};
const sizeStyles: Record<Size, string> = {
sm: "[--inspector-height:--spacing(8)] [--inspector-pad:--spacing(2.5)]",
default: "[--inspector-height:--spacing(9)] [--inspector-pad:--spacing(3)]",
lg: "[--inspector-height:--spacing(11)] [--inspector-pad:--spacing(4)]",
};
interface InspectorSelectOption {
/** Value reported on selection. An empty string is not a valid option value. */
value: string;
/** Text shown in the row and in the menu. Falls back to `value`. */
label?: string;
/** Second line under the label, in the menu only. */
description?: string;
/** Leading icon, shown in the menu and in the row once selected. */
icon?: LucideIcon;
/** Leading colour dot in any CSS colour — palettes, themes, brand colors. */
swatch?: string;
/**
* Heading this option sits under. Consecutive options that share a group form
* one section, so the authored order is always preserved.
*/
group?: string;
disabled?: boolean;
}
/** One section of the menu: a run of options under an optional heading. */
interface OptionSection {
label?: string;
options: InspectorSelectOption[];
}
/**
* Normalize the options and slice them into sections. Kept outside the component
* so it stays a pure function of its input and can be memoized on `options`.
*/
function buildSections(entries: (string | InspectorSelectOption)[]): OptionSection[] {
const sections: OptionSection[] = [];
for (const entry of entries) {
const option = typeof entry === "string" ? { value: entry } : entry;
// An empty value is the "nothing selected" sentinel and throws if used for
// an item, so drop the entry rather than take the whole menu down with it.
if (!option.value) continue;
const current = sections[sections.length - 1];
if (current && current.label === option.group) {
current.options.push(option);
} else {
sections.push({ label: option.group, options: [option] });
}
}
return sections;
}
interface InspectorSelectProps {
/** Label rendered on the left, inside the row. */
label: string;
/** Optional leading icon shown before the label. */
icon?: LucideIcon;
/** Plain strings for simple lists, or objects for icons, swatches and groups. */
options?: (string | InspectorSelectOption)[];
/** Controlled value. Pair it with `onValueChange`. */
value?: string;
/** Initial value in uncontrolled mode. */
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Fires when the menu opens or closes. */
onOpenChange?: (open: boolean) => void;
/**
* Shown in place of the value until something is selected.
* @defaultValue "Select" */
placeholder?: string;
/**
* Menu text when there is nothing to choose from.
* @defaultValue "No options" */
emptyMessage?: string;
/**
* Menu width: match the row, or size to the longest option.
* @defaultValue "trigger" */
menuWidth?: "trigger" | "auto";
/** Block interaction and dim the row. */
disabled?: boolean;
/**
* Surface treatment: filled (default), hairline outline, or bare until hover.
* @defaultValue "muted" */
tone?: Tone;
/**
* Row height preset.
* @defaultValue "default" */
size?: Size;
/** Name of the underlying form control, so the row can take part in a form. */
name?: string;
/** Id of the trigger, for an external `<label htmlFor>`. */
id?: string;
className?: string;
/**
* Accessible name. Omit it and the row reads as "{label}, {value}", which is
* usually what you want — the visible label and the value are both announced.
*/
"aria-label"?: string;
}
export const inspectorSelectDemo: InspectorSelectProps = {
label: "Blend mode",
className: "w-72",
defaultValue: "screen",
options: [
{ value: "normal", label: "Normal" },
{
value: "multiply",
label: "Multiply",
group: "Darken",
description: "Keeps the darker pixels",
},
{ value: "color-burn", label: "Color burn", group: "Darken" },
{
value: "screen",
label: "Screen",
group: "Lighten",
description: "Keeps the lighter pixels",
},
{ value: "color-dodge", label: "Color dodge", group: "Lighten" },
],
};
export function InspectorSelect({
label,
icon: Icon,
options = [],
value: valueProp,
defaultValue,
onValueChange,
onOpenChange,
placeholder = "Select",
emptyMessage = "No options",
menuWidth = "trigger",
disabled = false,
tone = "muted",
size = "default",
name,
id,
className,
"aria-label": ariaLabel,
}: InspectorSelectProps) {
const sections = React.useMemo(() => buildSections(options), [options]);
// The row draws its own trigger content, so it has to know the value in both
// modes. Keeping the fallback an empty string means the underlying select is
// controlled from the first render and never switches modes mid-life.
const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
const value = valueProp ?? internalValue;
const selected = React.useMemo(() => {
if (!value) return undefined;
for (const section of sections) {
for (const option of section.options) {
if (option.value === value) return option;
}
}
return undefined;
}, [sections, value]);
const handleValueChange = (next: string | null) => {
// Base UI reports null when a selection is cleared; this row never clears,
// but guard it so a null can never reach `onValueChange`.
if (next === null) return;
if (valueProp === undefined) setInternalValue(next);
onValueChange?.(next);
};
const SelectedIcon = selected?.icon;
return (
<Select
value={value}
onValueChange={handleValueChange}
onOpenChange={onOpenChange}
disabled={disabled}
name={name}
>
<SelectTrigger
id={id}
size="default"
aria-label={ariaLabel}
className={cn(
// Family tokens, mirrored from inspector-slider so a column of rows
// lines up whatever the mix of controls.
"[--inspector-radius:var(--radius-xl)]",
sizeStyles[size],
"w-full cursor-pointer justify-between gap-2 py-0 shadow-none select-none",
"rounded-(--inspector-radius) px-(--inspector-pad) data-[size=default]:h-(--inspector-height)",
"text-sm font-medium transition-colors",
"focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
// The trigger appends the chevron as its own last child. Bring it up to
// full opacity and turn it over while the menu is open.
"[&>svg:last-child]:opacity-100 [&>svg:last-child]:transition-transform data-[state=open]:[&>svg:last-child]:rotate-180",
toneStyles[tone],
className
)}
>
<span className="flex min-w-0 items-center gap-1.5 text-foreground/70">
{Icon ? <Icon className="size-4 shrink-0" /> : null}
<span className="truncate">{label}</span>
</span>
<span
className={cn(
"ml-auto flex min-w-0 items-center gap-1.5",
selected ? "text-foreground" : "text-foreground/70"
)}
>
{selected?.swatch ? (
<span
aria-hidden="true"
className="size-3 shrink-0 rounded-full border border-border"
style={{ backgroundColor: selected.swatch }}
/>
) : null}
{SelectedIcon ? <SelectedIcon className="size-4 shrink-0" /> : null}
<span className="truncate">
{selected ? (selected.label ?? selected.value) : placeholder}
</span>
</span>
</SelectTrigger>
{/*
No `sideOffset` of its own: the wrapper already nudges a popper-positioned
menu down by 4px, so adding one on top only pushed the menu away from the
row it belongs to. This is the gap every other select in the app has.
*/}
<SelectContent
position="popper"
align="start"
className={cn(
"[--inspector-radius:var(--radius-xl)] rounded-(--inspector-radius)",
// The menu is portaled out of the row, so it carries its own copy of
// the radius token instead of inheriting one.
menuWidth === "trigger" && "w-(--radix-select-trigger-width)"
)}
>
{sections.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-foreground/70 select-none">{emptyMessage}</div>
) : (
sections.map((section, index) => (
<React.Fragment key={`${index}-${section.label ?? ""}`}>
{index > 0 ? <SelectSeparator /> : null}
<SelectGroup>
{section.label ? (
<SelectLabel className="text-sm font-medium select-none">
{section.label}
</SelectLabel>
) : null}
{section.options.map((option) => {
const OptionIcon = option.icon;
// A described option is two lines tall, so its leading mark
// lines up with the first line instead of the block.
const leadingAlign = option.description ? "mt-0.5" : undefined;
return (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
className={cn(
"cursor-pointer rounded-md",
option.description && "items-start *:[span]:last:items-start"
)}
>
{option.swatch ? (
<span
aria-hidden="true"
className={cn(
"size-3 shrink-0 rounded-full border border-border",
leadingAlign
)}
style={{ backgroundColor: option.swatch }}
/>
) : null}
{OptionIcon ? (
<OptionIcon className={cn("size-4 shrink-0", leadingAlign)} />
) : null}
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate">{option.label ?? option.value}</span>
{option.description ? (
<span className="truncate text-sm text-foreground/70 select-none">
{option.description}
</span>
) : null}
</span>
</SelectItem>
);
})}
</SelectGroup>
</React.Fragment>
))
)}
</SelectContent>
</Select>
);
}
registry-components/inspector-tabs/inspector-tabs.tsx
"use client";
import type { LucideIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
/** Surface treatment of the strip. Mirrors inspector-slider. */
type Tone = "muted" | "outline" | "ghost";
/** Strip height preset. Mirrors inspector-slider's row heights. */
type Size = "sm" | "default" | "lg";
const toneStyles: Record<Tone, string> = {
muted: "border border-transparent bg-muted",
outline: "border border-border",
ghost: "border border-transparent",
};
const sizeStyles: Record<Size, string> = {
sm: "[--inspector-height:--spacing(8)] [--inspector-pad:--spacing(2.5)]",
default: "[--inspector-height:--spacing(9)] [--inspector-pad:--spacing(3)]",
lg: "[--inspector-height:--spacing(11)] [--inspector-pad:--spacing(4)]",
};
interface InspectorTab {
/** Reported on selection, and what a controlled value is matched against. */
value: string;
/** How it reads. Falls back to `value`. */
label?: string;
/** Leading glyph. On its own, with no label, it needs `label` for the tooltip. */
icon?: LucideIcon;
/**
* A count beside the label. How many settings under this tab differ from their
* defaults, usually: a tab that hides changes is worse than no tab at all.
*/
badge?: React.ReactNode;
/**
* Id of the element this tab shows, when the caller renders one. It is the only
* thing that ties a strip to a body, and without it the strip is simply a
* switcher, which is a thing panels need on its own.
*/
controls?: string;
disabled?: boolean;
}
interface InspectorTabsProps {
/** The tabs, in the order they are shown. */
tabs?: InspectorTab[];
/** Controlled selection, by `value`. Pair it with `onValueChange`. */
value?: string;
/** Initial selection in uncontrolled mode. Falls back to the first tab. */
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Block interaction and dim the strip. */
disabled?: boolean;
/**
* Surface treatment of the strip: filled (default), hairline outline, or bare.
* @defaultValue "muted" */
tone?: Tone;
/**
* Strip height preset.
* @defaultValue "default" */
size?: Size;
className?: string;
/** Accessible name for the strip. */
"aria-label"?: string;
}
export const inspectorTabsDemo: InspectorTabsProps = {
className: "w-72",
tabs: [
{ value: "design", label: "Design" },
{ value: "content", label: "Content", badge: 2 },
],
};
/**
* The other thing a panel is made of. inspector-group puts a handful of rows away
* behind a heading; this splits the whole panel, which is the move a drawer needs
* once it holds thirty rows that answer to two or three different questions.
*
* It is the strip and nothing else. It used to render a body under itself too, and
* that was one job too many: what the tabs switch between is the panel's own layout,
* held in the panel's own markup, next to the state that decides it. A component that
* owns the strip and the body has to be told about both, and is then in the way of
* every arrangement it did not anticipate. Point `controls` at your body if you have
* one; a strip with no body at all is a switcher, which panels want just as often.
*/
export function InspectorTabs({
tabs = [],
value: valueProp,
defaultValue,
onValueChange,
disabled = false,
tone = "muted",
size = "default",
className,
"aria-label": ariaLabel,
}: InspectorTabsProps) {
const [internalValue, setInternalValue] = React.useState(
defaultValue ?? tabs[0]?.value ?? "",
);
const value = valueProp ?? internalValue;
const baseId = React.useId();
const stripRef = React.useRef<HTMLDivElement | null>(null);
const activeIndex = Math.max(
tabs.findIndex((tab) => tab.value === value),
0,
);
const select = (next: string) => {
if (valueProp === undefined) setInternalValue(next);
onValueChange?.(next);
};
/*
* Automatic activation: an arrow key both moves and selects. That is the pattern
* for a tab set whose panels are already in the document and cost nothing to show,
* and it is what a reader stepping through a panel expects. The alternative asks
* them to press Enter after every arrow.
*/
const move = (delta: number) => {
if (tabs.length === 0) return;
let next = activeIndex;
for (let attempt = 0; attempt < tabs.length; attempt += 1) {
next = (next + delta + tabs.length) % tabs.length;
if (!tabs[next]?.disabled) break;
}
const target = tabs[next];
if (!target || target.disabled) return;
select(target.value);
// Focus follows the selection, or the next arrow starts from where the
// keyboard was rather than from what is on screen.
stripRef.current
?.querySelectorAll<HTMLButtonElement>("[data-slot=inspector-tabs-tab]")
?.[next]?.focus();
};
const handleKeyDown = (event: React.KeyboardEvent) => {
switch (event.key) {
case "ArrowRight":
event.preventDefault();
move(1);
break;
case "ArrowLeft":
event.preventDefault();
move(-1);
break;
case "Home": {
event.preventDefault();
const first = tabs.find((tab) => !tab.disabled);
if (first) select(first.value);
break;
}
case "End": {
event.preventDefault();
const last = [...tabs].reverse().find((tab) => !tab.disabled);
if (last) select(last.value);
break;
}
default:
break;
}
};
return (
<div
ref={stripRef}
data-slot="inspector-tabs"
data-disabled={disabled}
className={cn(
"h-(--inspector-height) rounded-(--inspector-radius) p-1",
"[--inspector-radius:var(--radius-xl)]",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
sizeStyles[size],
toneStyles[tone],
className,
)}
>
{/*
A grid of equal columns rather than a row of flexible ones, which is what lets
the pill below be placed by index alone: no measuring, and the slide runs on
the compositor. It is the same arrangement inspector-segmented uses, and the
reason this strip can afford it is that every tab is the same width by
construction, whatever its label says.
The tablist is this inner element rather than the padded surface around it, so
the marker can be positioned against the space the tabs actually occupy.
*/}
<div
role="tablist"
aria-label={ariaLabel}
aria-orientation="horizontal"
onKeyDown={handleKeyDown}
data-slot="inspector-tabs-list"
className="relative grid h-full items-center"
style={{ gridTemplateColumns: `repeat(${Math.max(tabs.length, 1)}, minmax(0, 1fr))` }}
>
<span
aria-hidden="true"
data-slot="inspector-tabs-marker"
className={cn(
"pointer-events-none absolute inset-y-0 left-0 rounded-lg bg-background shadow-sm",
"motion-safe:transition-transform motion-safe:duration-200",
)}
style={{
width: `${100 / Math.max(tabs.length, 1)}%`,
transform: `translateX(${activeIndex * 100}%)`,
}}
/>
{tabs.map((tab, index) => {
const isActive = index === activeIndex;
const TabIcon = tab.icon;
return (
<button
key={tab.value}
type="button"
role="tab"
id={`${baseId}-tab-${tab.value}`}
aria-selected={isActive}
aria-controls={tab.controls}
tabIndex={isActive ? 0 : -1}
disabled={disabled || tab.disabled}
title={tab.label ?? tab.value}
onClick={() => select(tab.value)}
data-slot="inspector-tabs-tab"
data-active={isActive || undefined}
className={cn(
// `relative`, so the tab sits over the marker sliding underneath it.
"relative flex h-full min-w-0 cursor-pointer items-center justify-center gap-1.5",
"rounded-lg px-2",
"text-sm font-medium transition-colors select-none",
"outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:opacity-40",
isActive ? "text-foreground" : "text-foreground/70 hover:text-foreground",
)}
>
{TabIcon ? <TabIcon className="size-4 shrink-0" /> : null}
{tab.label ? <span className="truncate">{tab.label}</span> : null}
{tab.badge !== undefined && tab.badge !== null ? (
<span
data-slot="inspector-tabs-badge"
className={cn(
"flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full px-1",
"text-sm tabular-nums",
isActive ? "bg-foreground/10 text-foreground" : "bg-foreground/10",
)}
>
{tab.badge}
</span>
) : null}
</button>
);
})}
</div>
</div>
);
}