For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0
Sponsor

Resolution Segmented

A segmented resolution row with a sliding plate and a dot matrix that carries sampling density — two dots standard, three high, four retina.

Resolution

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.

Output

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

resolution-segmented.tsx
"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>
  );
};