value: 2K
"use client";
import { useState } from "react";Installation
$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/resolution-segmented.json
Usage
import { ResolutionSegmented } from "@/components/ui/resolution-segmented";
export function Output() {
const [resolution, setResolution] = useState("2K");
return (
<div className="space-y-2">
<div className="font-medium text-muted-foreground text-xs">
Resolution
</div>
<ResolutionSegmented
aria-label="Resolution"
onChange={setResolution}
value={resolution}
/>
</div>
);
}The heading is yours
The control draws no label. A field's label belongs to the form around it — above,
beside, in a <legend> — and that is layout the caller owns, not something a select
should decide for the page it lands in. So the component renders the row and nothing
else.
That leaves one line of wiring: a heading you write is plain text, so pass aria-label
with the same words and the name reaches a screen reader too.
The plate, not the button
The selected state is a plate that slides between cells rather than a cell that lights up
and another that goes dark. A highlight appearing and disappearing in two places reads as
two independent buttons; one plate travelling between positions reads as a single control
changing state, which is what it is. The travel uses the same 0.25rem gutter the grid
uses, so the plate lands exactly on the cell it belongs to rather than near it, and
motion-reduce:transition-none parks it instantly for anyone who has asked for less
movement.
The dot matrix
Resolution is sampling density, so the glyph is a matrix of dots and its count encodes
the tier: two dots for standard, three for high, four for retina. The mapping is
positional — the component never parses "2K" — which is why it works for any list of
resolutions you pass. Two options get the endpoints (two dots and four) instead of a false
middle tier.
Because the tiers are ordered, keep options in ascending order; the first entry is the
one the glyphs treat as the smallest.
Accessibility
The row is a group of <button>s with aria-pressed, focusable and operable by keyboard.
The grid is a role="group" named by aria-label, so it is announced as one control. The
dots are aria-hidden — the tier is decorative and the value already says 2K.
Component source
"use client";
import { cn } from "@/lib/utils";
/* -- The glyph -----------------------------------------------------------------
* Resolution is sampling density, so the glyph is a dot matrix rather than a number
* in a box: two dots read as standard, three as high, four as retina. The count is
* positional — tiers are ordered, not named — so any list of resolutions gets the
* right density without the component knowing what `"2K"` means.
* --------------------------------------------------------------------------- */
/** Resolutions offered when the caller does not pass their own. */
const DEFAULT_OPTIONS = ["1K", "2K", "4K"] as const;
/** Dot rows per tier, largest last. Two options get the ends; three get the full run. */
const TIERS = [2, 3, 4] as const;
/** Coordinates for each tier's dots, in a 16×16 viewBox, paired with the dot radius. */
const DOTS: Record<
(typeof TIERS)[number],
{ r: number; points: [number, number][] }
> = {
2: {
points: [
[4.5, 4.5],
[11.5, 4.5],
[4.5, 11.5],
[11.5, 11.5],
],
r: 1.6,
},
3: {
points: [
[3, 3],
[8, 3],
[13, 3],
[3, 8],
[8, 8],
[13, 8],
[3, 13],
[8, 13],
[13, 13],
],
r: 1.1,
},
4: {
points: [
[2.5, 2.5],
[6.5, 2.5],
[10.5, 2.5],
[14.5, 2.5],
[2.5, 6.5],
[6.5, 6.5],
[10.5, 6.5],
[14.5, 6.5],
[2.5, 10.5],
[6.5, 10.5],
[10.5, 10.5],
[14.5, 10.5],
[2.5, 14.5],
[6.5, 14.5],
[10.5, 14.5],
[14.5, 14.5],
],
r: 0.8,
},
};
/** Two tiers skip the middle of the run — endpoints only, no false intermediate. */
const tierFor = (index: number, total: number) => {
if (total <= 2) {
return index === 0 ? 2 : 4;
}
return TIERS[Math.min(index, TIERS.length - 1)];
};
const ResolutionMatrixIcon = ({
index,
isSelected,
total,
}: {
index: number;
isSelected: boolean;
total: number;
}) => {
const tier = tierFor(index, total);
const { r, points } = DOTS[tier];
return (
<svg
aria-hidden="true"
className={cn(
"size-4 shrink-0 transition-opacity",
isSelected
? "text-foreground opacity-90"
: "text-muted-foreground opacity-50"
)}
viewBox="0 0 16 16"
>
{points.map(([cx, cy]) => (
<circle cx={cx} cy={cy} fill="currentColor" key={`${cx}-${cy}`} r={r} />
))}
</svg>
);
};
export interface ResolutionSegmentedProps {
/**
* Names the row for assistive tech, e.g. `"Resolution"` or `"Output"`. The heading
* above the control is the caller's to draw — the component ships no label, because a
* field's label belongs to the form around it.
*/
"aria-label"?: string;
className?: string;
/** Called with the chosen resolution, e.g. `"2K"`. */
onChange: (value: string) => void;
/** Tiers to offer, low to high. Three is the designed-for count. */
options?: readonly string[];
/** The resolution currently in effect. */
value: string;
}
/**
* Resolution as a segmented row with a sliding plate. The plate does the moving, so
* the choice reads as one control changing position rather than one button lighting
* up and another going dark — and the dot matrix says how much detail each stop buys.
*/
export const ResolutionSegmented = ({
"aria-label": ariaLabel,
className,
onChange,
options = DEFAULT_OPTIONS,
value,
}: ResolutionSegmentedProps) => {
const selectedIndex = options.indexOf(value);
const colCount = Math.max(1, options.length);
return (
<div
className={cn(
"rounded-xl border border-border/50 bg-muted/20 p-1",
className
)}
>
<div
aria-label={ariaLabel}
className="relative grid gap-1"
role="group"
style={{ gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }}
>
{selectedIndex === -1 ? null : (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 rounded-lg border border-border/80 bg-accent shadow-xs transition-transform duration-[220ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
style={{
// Same 0.25rem gutter the grid uses, so the plate lands exactly on a cell.
transform: `translateX(calc(${selectedIndex} * (100% + 0.25rem)))`,
width: `calc((100% - ${(colCount - 1) * 0.25}rem) / ${colCount})`,
}}
/>
)}
{options.map((optionValue, index) => {
const isSelected = value === optionValue;
return (
<button
aria-pressed={isSelected}
className={cn(
"relative z-10 flex h-8 min-w-0 items-center justify-center gap-1 rounded-lg border border-transparent px-2 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50",
isSelected
? "font-semibold text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
)}
key={optionValue}
onClick={() => onChange(optionValue)}
type="button"
>
<ResolutionMatrixIcon
index={index}
isSelected={isSelected}
total={colCount}
/>
<span className="leading-none tracking-tight">{optionValue}</span>
</button>
);
})}
</div>
</div>
);
};