Field Row
Label + control inspector row with unit-suffixed value input.
Playback rate of the clip.
What it is
One labelled parameter on a shared column grid: a label, whatever control sets the value, and an optional reset beside it, with an optional explanation line underneath. It ships no control of its own — the control column is a render prop that hands you the id to put on your control and the id of the hint to describe it with. The one control it does ship is UnitInput, a number field that carries its unit inside the box.
Why it matters
Every inspector in this system is a stack of these, so the grid is the reason a panel reads as one thing rather than a pile of controls: labels line up, values line up, resets line up, whatever each row happens to hold. CapCut, Canva, Spline and Tripo all ship the same row, and after preview-tile it is the most reused primitive in the catalog — parameter-panel and property-inspector are both built from it. The render prop is what buys that: because the row never owns the control, a slider row and a switch row and a colour row are the same component and cannot drift apart.
Observed in: CapCut · Canva · Spline · Tripo
Anatomy
- 1
field-rowRoot. Stacks the label/control grid over the optional hint line. - 2
field-row-labelThe visible label, in a fixed 6rem column. A real <label for>, pointed at the id the render prop hands you. - 3
field-row-controlThe control column. Holds whatever your render prop returns, then the reset slot. - 4
field-row-resetTrailing slot, present only whenresetis passed — normally a reset-affordance (A11). - 5
field-row-hintThe explanation line under the row. Its id arrives as the render prop's second argument; wire it with aria-describedby. - 6
unit-inputUnitInput's border box. It owns the focus ring, via focus-within. - 7
unit-input-unitThe unit suffix inside that box — %, s, ×, px. Decorative text, not a label.
How to use it
Reach for it whenever a surface sets a named parameter: a generation panel, a properties inspector, a settings group. Give it a label and a function for children — you get back `(controlId, describedBy)`, and your job is to put `controlId` on the control so the visible label really names it, and `describedBy` on the same control when you passed a `hint`. For a numeric parameter use the `UnitInput` it ships so the unit reads as a suffix rather than as separate text. Add `reset` once a row is bound to a value a user can move away from. Stack rows directly — do not wrap each one in its own card — because the shared column grid only pays off when the rows are siblings.
Do
Carry the unit inside the field with UnitInput, so the number is self-describing wherever it is read.
×Apply both render-prop arguments:
controlIdon the control, anddescribedByon it too whenever you passed a hint.Higher values follow the prompt more literally.
Put the reset in the
resetslot so it lands in the row's grid and lines up with every other row's reset.%%
Don't
Don't spell the unit into the label text — the label column is fixed at 6rem and the value stops being self-describing the moment it leaves the row.
Don't render a hint and drop
describedBy— the explanation becomes sighted-only, and nothing in the row can detect it.Higher values follow the prompt more literally.
Don't hang the reset off the bottom of the row instead of using the slot; it leaves the grid and adds a line of height the stack's rhythm depends on not having.
%
Accessibility
- Keyboard
- The row is zero tab stops of its own — it is a `<div>`, a `<label>` and a `<p>`. Every stop it has comes from whatever your render prop returns, plus one more when you pass `reset`.
- `UnitInput` is one stop and a native number spinner: Up and Down step the value. The visible spinner buttons are removed (`[appearance:textfield]` plus the webkit override), so the arrow keys are the only stepper and a mouse user has no increment control at all.
- A `reset-affordance` in the `reset` slot is `disabled` while the row sits at its default, so it is not tabbable. The row's tab-stop count changes as the value changes.
- There is no `disabled` on the row. Disabling is something you do to the control you passed in, so the label keeps its full opacity and anything in the `reset` slot stays live unless you disable that too.
- Screen reader
- `<label for>` plus the id from the render prop is the entire naming story. Return a control without applying `controlId` and the label points at nothing: the control ships unnamed and the label text is announced as loose text beside it.
- The hint is a plain `<p>` with an id, and nothing wires it for you. `describedBy` is the render prop's second argument and applying it is yours to do, so an unwired hint is sighted-only — and no gate can see the omission.
- `UnitInput`'s suffix is a sibling `<span>` of the input, not part of its name or its value. The field announces as a spin button reading "0.5", never "0.5 seconds", so put the unit in the label too when the bare number is ambiguous.
- One `controlId` per row, and no group role around the control column. A two-control row — an x/y pair, a slider beside its number field — can name only one of the two, and there is no group name to say the pair belongs together.
- Nothing here announces a value change. The row owns no live region, so a reset that moves a value from 1.5 back to 1.0 is silent beyond whatever the control itself reports.
- Focus
- `UnitInput` draws its ring on the wrapper with `focus-within:ring-2` while the input itself sets `outline-none`. Restyle `unit-input`'s className without keeping `focus-within:ring-2` and the field has no focus indicator at all.
- Pressing a `reset-affordance` in the `reset` slot returns the row to default — which is the state that disables that same button. Focus is dropped from the now-disabled element and falls to `<body>`, so the next Tab restarts from the top of the page.
- Clicking the visible label moves focus to the control. Clicking the unit suffix does not; it is inert text, and the gap is marked as a TODO in the source.
- Whatever the render prop returns brings its own focus style. The row supplies none.
Watch out for
- The row hands out exactly one
controlId, so a two-control row — an x/y pair, a slider plus its number field — can only associate the visible label with one of them. Give every extra control its ownaria-label; the row has no group-label shape to express the relationship for you. <label for>only reaches a labelable element, and some controls are not one even though they look like it. Base UI's Switch renders a span with role="switch" beside a visually hidden proxy input, and it is the proxy that takes yourcontrolId— it still works, because Base UI finds the label through that proxy and points the visible switch at it, but two nodes now answer to the label text, so query such a control by role and name rather than by label. A control with no proxy at all, like a Base UI slider thumb, gets nothing: name it yourself withgetAriaLabel, which is what parameter-panel.tsx does.- The row exposes the control's id and the hint's id, never the label's. A control that can only be named with
aria-labelledbyhas nothing to point at, so the label text has to be repeated as a string — and a repeated string is one that can drift. - There is no
disabledprop on the row. The root is a div, so disabling is something you do to the control you passed in — which means the label does not dim with it. If a whole row should read as unavailable, dim the label yourself. UnitInputreports through two callbacks and they are not the same:onValueChangegives you a number and only fires when the field parses to one, whileonChangegives you the raw event and fires every time. Clearing the field is a change with no value, so a consumer listening only toonValueChangenever learns the field was emptied.- The unit suffix is inert text. Clicking it does not focus the field the way clicking the label does — a known gap, marked in the source.
Installation
npx shadcn@latest add https://super-ai-components.vercel.app/r/field-row.jsonRequires a shadcn app on Base UI (style base-nova). Radix-based styles such as new-york fail to typecheck: these components use Base UI's render prop and callbacks like onOpenChangeComplete, which Radix primitives do not have.