VI Controls

Star on GitHubFeedback
VI ControlsRadixot-toggles

Toggle group

Words in a row you can hold down, one or many; held neighbours are tied under one slur or share one pair of parentheses, held words are numbered in the order you held them, or cross into the margin.

Report an issue with Toggle group

Example

Format
Size

Silence is structure.

Rank what matters
Show on the page
Pointed at
Focus
Held
Held together
Disabled
Bracket, pointed at
Bracket, focus
Bracket, held
Bracket, held together
Fingering, pointed at
Fingering, held in turn
Fingering, disabled
Margin, pointed at
Margin, held
Margin, focus
Bracket, disabled

Install

Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base along.

npx shadcn@latest add https://thedirectors.agency/ui/r/toggle-group.json

Usage

toggle-group-example.tsx
"use client"import * as React from "react"import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"const sizes = ["p", "mp", "mf", "f"] as constexport default function Example() {const [format, setFormat] = React.useState(["italic"])const [size, setSize] = React.useState<string>("mf")const has = (f: string) => format.includes(f)return (<div className="grid justify-items-start gap-8"><div className="grid justify-items-start gap-2"><span className="ot-label">Format</span><ToggleGroup type="multiple" value={format} onValueChange={setFormat} aria-label="Format"><ToggleGroupItem value="bold">Bold</ToggleGroupItem><ToggleGroupItem value="italic">Italic</ToggleGroupItem><ToggleGroupItem value="underline">Underline</ToggleGroupItem><ToggleGroupItem value="wide">Wide</ToggleGroupItem></ToggleGroup></div><div className="grid justify-items-start gap-2"><span className="ot-label">Size</span>{/* Radix single groups clear on a second press; a size is always one of the four. */}<ToggleGroup variant="bracket" type="single" value={size} onValueChange={(v) => v && setSize(v)} aria-label="Size">{sizes.map((s) => (<ToggleGroupItem key={s} value={s}>{s}</ToggleGroupItem>))}</ToggleGroup></div><p className="text-balance" style={{color: "var(--ot-ink)",fontSize: `var(--ot-${size})`,lineHeight: `var(--ot-${size}-lh)`,fontFamily: has("italic") ? "var(--ot-expression)" : undefined,fontStyle: has("italic") ? "italic" : undefined,fontWeight: has("bold") ? 700 : 400,fontStretch: has("wide") ? "125%" : "100%",textDecorationLine: "underline",textDecorationColor: has("underline") ? "currentColor" : "transparent",textDecorationThickness: "var(--ot-hairline)",textUnderlineOffset: "0.18em",transition: "font-size var(--ot-moderato) var(--ot-exhale), font-weight var(--ot-moderato) var(--ot-exhale), font-stretch var(--ot-moderato) var(--ot-exhale), text-decoration-color var(--ot-allegro)",}}>Silence is structure.</p><div className="grid justify-items-start gap-2"><span className="ot-label">Rank what matters</span><ToggleGroup variant="fingering" type="multiple" defaultValue={["purpose", "clarity"]} aria-label="Rank what matters">{["Clarity", "Purpose", "Simplicity", "Impact"].map((w) => (<ToggleGroupItem key={w} value={w.toLowerCase()}>{w}</ToggleGroupItem>))}</ToggleGroup></div><div className="grid justify-items-start gap-2"><span className="ot-label">Show on the page</span><ToggleGroup variant="margin" type="multiple" defaultValue={["baselines", "captions"]} aria-label="Show on the page">{["Grid", "Baselines", "Margins", "Captions"].map((w) => (<ToggleGroupItem key={w} value={w.toLowerCase()}>{w}</ToggleGroupItem>))}</ToggleGroup></div></div>)}

Contract

  • Underneath: Radix ToggleGroup: type="multiple" is a toolbar of aria-pressed buttons, type="single" a radio group. Needs no other item's classes.
  • Anatomy: <div class="ot-toggles" data-variant="slur | bracket | fingering | margin" aria-label="Name"> of <button class="ot-toggles-item" data-value="…">, each word in a ot-toggles-word cell that also holds its italic copy (from data-text), so a word is always as wide as its wider state and nothing beside it moves when it turns. Last, an aria-hidden ot-toggles-marks layer where script draws one ot-toggles-mark per run of held neighbours from --l, --r, --t, --h.
  • Held, a word is yours: the roman sinks away as the italic rises into its place. Its mark stretches over the held words beside it; when two runs join, one mark stretches and the other fades where it stands. Held one at a time, the mark glides to the new word, leading edge first.
  • Marks are measured from the face you see: the held italic, or the roman at rest (which carries its own pencil sketch), never the shared cell, so a slur, a pair or a number sits even on the word.
  • slur (default): hairlines stand between the words, and the held run is tied under one engraved slur (a border on a half-ellipse, so it swells in the middle and tapers to points; it bows deeper the further it reaches). The hairline between two held words lies down under the slur, and none stands at the start of a wrapped line. Pointing sketches a pencil slur from the middle out.
  • bracket: no hairlines; the held run is set in italic parentheses, after 20(25), and neighbours share one pair. Pointing sketches a pencil pair standing a little apart; holding closes the ink pair in. The room for the parentheses hangs into the margin, so words at rest line up with the label.
  • fingering: after the score's fingering, the small numbers over the notes that say which finger plays each, and the "01" of "Less is more.". No hairlines. Each held word carries a small italic numeral over its middle: the order you held them in (--n, the value's order). Pointing at a free word pencils the number it would get (--next); holding inks it and it lands with spiccato; letting one go counts the later ones down. Meant for type="multiple".
  • margin: after the book's side head, set in the margin beside the column it heads. The words stand in a column, flush against one hairline, with an empty margin as wide as the column on its other side (two equal tracks). Held, a word crosses the hairline into the margin and turns italic, flush against the hairline's other side, so what you hold reads down the margin and the rest down the column. Pointing leans the word toward the hairline. Mirrored right to left.
  • The focus ring rings the button, so it holds the word with its mark, and closes in from 12px with spiccato. The first word of each line starts at its cell's start edge; the rest are centred in theirs.
  • States: rest, hover (pencil mark), focus, held, held together, disabled (the mark in pencil); fingering pointed at, held in turn and disabled; margin pointed at, held and focus.
  • Keyboard: Tab into the group, arrows rove (all four arrows, so the margin's column reads with Up and Down) (following the direction), Home and End, Space or Enter holds. Left without dir, the group takes the page's direction.

Props

Props of Toggle group
PropTypeDefaultWhat it does
variant"slur" | "bracket" | "fingering" | "margin""slur"How held words are marked. slur: hairlines stand between the words, and a run of held neighbours is tied under one engraved slur. bracket: no hairlines; the held words are set in italic parentheses, and neighbours share one pair. fingering: no hairlines; each held word gets a small italic numeral over it, the order you held them in, like the fingering over a score's notes; pointing at a free word pencils the number it would get, and letting one go counts the later ones down. Use it with type multiple. margin: the words stand in a column, flush against a hairline with an empty margin as wide as the column on its other side; a held word crosses the hairline into the margin and turns italic, flush against the hairline's other side, like a book's side head, so what you hold reads down the margin. Pointing leans a word toward the hairline.
type"single" | "multiple"One held word at a time, or any number. Held one at a time, the mark glides from the old word to the new one.
value / defaultValue / onValueChangestring | string[]What's held: a string for single, an array for multiple.
ToggleGroupItem valuestringEach word in the group. Arrow keys move between them, Home and End go to the ends, Space holds.
dir"ltr" | "rtl"Left out, the group takes the direction of the page around it, words and arrow keys alike.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
Toggle groupThe score's slur; 20(25)Held neighbours are tied under one slur, or share one pair of parentheses
Fingering groupThe score's fingering; "Less is more." 01Each held word numbered in the order you held it
Margin groupThe book's side headA held word crosses the hairline into the margin

How it moves

How it moves
ComponentArticulationWhat moves
Fingering groupSpiccatoThe number lands on the held word; pointing pencils the next one; letting one go counts the later ones down
Margin groupCrossingThe held word crosses the hairline into the margin as it turns italic; pointing leans it toward the line
Toggle groupLegatoThe slur or the pair stretches over a held neighbour and glides to a new choice, leading edge first; the word turns italic as it rises