Canvas Design System
Main Site Tokens

Form Row

Wraps a field, or a group of fields, in a consistent label, helper, and error layout. The label names what it wraps.

When to Use

Use when: You need to label something that has no label option of its own — a group of related controls, a custom widget, a pair of fields under one heading. Also useful for the columns span in multi-column form grids.
Don't use when: The field already accepts a label — which Input and Select both do. Use the field's own label, hint and error; a row around it adds nothing but a second wrapper. Never label both the field and the row: the control would carry two names.
info The label is bound (since October 2026). With one control inside, the row gives its label for that control, adding an id if the control has none, and points the control's aria-describedby at the helper or error. With several controls, the row is a role="group" named by its label. Before this, the label carried no for and a field labelled only here had no accessible name.

Variants

Stacked (default)

Label above, field below, helper underneath. The standard vertical form rhythm.

We'll never share your email.

<?= ws_form_row(
    ws_input('email', ['type' => 'email', 'placeholder' => 'you@example.com']),
    [
        'label'    => 'Email address',
        'required' => true,
        'helper'   => "We'll never share your email.",
    ]
) ?>

Inline

Label beside the field. Use it in settings panels where a column of labels aids scanning; avoid it on narrow layouts where it forces the field too small.

<?= ws_form_row(ws_input('name'), ['label' => 'Full name', 'inline' => true]) ?>

Wrapping a select

Any field HTML can be the content — the row doesn't care what it wraps.

expand_more

Select your role in the workshop.

<?= ws_form_row(
    ws_select('role', $roles, ['placeholder' => 'Choose a role…']),
    ['label' => 'Role', 'helper' => 'Select your role in the workshop.']
) ?>

Around content that helpers print

ws_form_row_start and ws_form_row_end take content written with helpers that print rather than return, such as a stack of switches, with no output buffering of your own. The end prints the row, so the label binding is the same as ws_form_row's: here two switches make the row a group named by its label.

Each one changes as soon as you switch it.

<?php ws_form_row_start(['label' => 'Email me about', 'helper' => 'Each one changes as soon as you switch it.']) ?>
    <?php ws_stack_start(['gap' => '3']) ?>
        <?= ws_toggle('reminders', ['label' => 'Session reminders']) ?>
        <?= ws_toggle('digest', ['label' => 'Weekly digest']) ?>
    <?php ws_stack_end() ?>
<?php ws_form_row_end() ?>

States

StateBehavior
DefaultLabel, field, and optional helper stacked with --space-1 / --space-4 rhythm.
Required and optionalrequired sets aria-required="true" on a single control that has neither required nor aria-required, and draws no asterisk: Workshopr defaults to required (DESIGN.md §11.3). optional appends "(optional)" to the label.
ErrorA non-empty error adds .ws-form-row--error and replaces the helper text with the message, which carries role="alert". A single control gets aria-invalid="true" and the error in its aria-describedby.
HelperShown below the field, hidden while an error is present.
Inline.ws-form-row--inline switches to a horizontal label/field arrangement.
Columnscolumns emits an inline grid-column: span N, letting one row span multiple columns of a grid form.

Real-World Usage

A two-column form where the email row spans both columns. Note the labels live on the fields, not on the rows — the rows are doing layout only, which is the pattern to copy.

<form style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
    <!-- Label on the field, so it is properly bound -->
    <?= ws_form_row(ws_input('first', ['label' => 'First name']), []) ?>
    <?= ws_form_row(ws_input('last',  ['label' => 'Last name']),  []) ?>

    <!-- Row spans both columns -->
    <?= ws_form_row(
        ws_input('email', ['type' => 'email', 'label' => 'Email']),
        ['columns' => 2]
    ) ?>
</form>

Options

OptionTypeDefaultPurpose
$contentstring—Positional. The field HTML, typically ws_input() or ws_select() output. Rendered raw.
labelstring''Row label. Bound to a single control with for, or names the row as a group.
requiredboolfalseAsterisk, plus aria-required on a single control. Set required on the field itself when the browser should enforce it.
helperstring''Help text below the field. Note: helper, matching Select rather than Input's hint.
errorstring''Error message. Replaces the helper and adds the error class.
inlineboolfalseHorizontal label and field
columnsintnullGrid column span, as an inline style
idstring''ID on the row wrapper, not the field
classstring''Additional CSS classes

Accessibility

ConcernBehavior
Label bindingOne control inside (hidden inputs aside): the label gets for its id, and the row adds an id when the control has none. Several controls, or none: the row is role="group" with aria-labelledby on its label, so a screen reader announces the group's name as focus enters it.
Error associationThe helper or error gets an id, and a single control lists it in aria-describedby, after any description the field already had (an Input hint stays). A group points its own aria-describedby at it. The error carries role="alert", so it is announced when it appears.
Required indicatorThe asterisk is aria-hidden; the requirement reaches assistive technology as aria-required on a single control.
KeyboardThe row adds nothing focusable. Clicking the label focuses a single control.
ContrastLabel uses --text-default, helper --text-muted, and error --color-error, all tested.

What to do instead

Do: Give a field that takes a label its own label, hint or helper, and error. Use the row's label for what has no label option: a group of related controls, a custom widget, a pair of fields under one heading.
Don't: Don't label both the row and the field inside it: the control ends up with two names, read one after the other.

Tokens

TokenUsed for
--text-defaultLabel colour
--text-mutedHelper text
--color-errorError text and the required asterisk
--text-small / --text-captionLabel and helper type scale
--font-mediumLabel weight
--space-1 / --space-4Label-to-field and field-to-helper spacing

CSS Classes

ClassPurpose
.ws-form-rowRow wrapper
.ws-form-row--inlineHorizontal label and field
.ws-form-row--errorError state on the row
.ws-form-row__labelLabel element, bound to a single control or naming the group
.ws-form-row__optionalThe "(optional)" marker in the label
.ws-form-row__fieldField container
.ws-form-row__helperHelper text
.ws-form-row__errorError text

Files

FilePurpose
includes/components/helpers.phpws_form_row() helper function
includes/components/form-row.phpTemplate — label, field slot, helper/error branch
includes/components/components.cssStyles (.ws-form-row rules)