The field wrapper¶
The single most important pattern to match. Every setting in the CP lives in a .field shell: a
heading with an optional required dot, instructions, the input, and any tip / warning / errors
below. The *Field macros build all of this for you.
How this entry is referenced in URLs.
use craft\helpers\Cp;
echo Cp::textFieldHtml([
'label' => Craft::t('app', 'Slug'),
'instructions' => Craft::t('app', 'How this entry is referenced in URLs.'),
'tip' => Craft::t('app', 'Leave blank to generate from the title.'),
'id' => 'slug',
'name' => 'slug',
'value' => $entry->slug,
'required' => true,
'errors' => $entry->getErrors('slug'),
]);
<div id="slug-field" class="field" data-attribute="slug">
<div class="heading">
<label id="slug-label" for="slug">
Slug
<span class="visually-hidden">Required</span>
<span class="required" aria-hidden="true"></span>
</label>
</div>
<div id="slug-instructions" class="instructions"><p>How this entry is referenced in URLs.</p></div>
<div class="input ltr"><input type="text" id="slug" class="text fullwidth" name="slug"></div>
<p id="slug-tip" class="notice has-icon">
<span class="icon" aria-hidden="true"></span>
<span class="visually-hidden">Tip: </span>
<span>Leave blank to generate from the title.</span>
</p>
</div>
Three things are easy to get wrong.
required is not a class on the label. It's a separate <span class="required" aria-hidden="true"> after the label text, preceded by a visually-hidden "Required" — the dot is decorative, and the word is what screen readers get. <label class="required"> does nothing.
A tip is <p class="notice has-icon">, not .tip. Warnings are <p class="warning has-icon">. Both carry a visually-hidden "Tip:" / "Warning:" prefix so the meaning isn't colour-only.
The container id is {id}-field, not {id}. Every sub-element gets a derived id — -label, -instructions, -tip, -warning, -errors, -status — and the macro wires them into the input's aria-describedby for you. Override id and they all move with it.
Config¶
| Config key | Effect |
|---|---|
label |
Heading above the input. "__blank__" renders no label at all |
fieldLabel |
Overrides label for the heading only |
instructions |
Subtle help text (Markdown supported) |
instructionsPosition |
before (default) or after the input |
required |
Adds the required dot and the visually-hidden "Required" |
tip / warning |
.notice / .warning line below the input |
errors |
Array of messages → <ul class="errors">, plus .has-errors on the field |
status |
[status, message] → a .status-badge in the corner |
static |
Adds a .read-only-badge beside the label |
fieldset |
Renders <legend> + role="group" instead of <label for> |
orientation |
ltr / rtl class on .input. Defaults to the site's locale |
translatable |
Shows the translation indicator (multi-site only) |
id / name / value |
Standard input wiring |
fieldClass |
Extra classes on the outer .field |
fieldAttributes |
Extra attributes on the outer .field |
inputContainerAttributes |
Extra attributes on .input |
labelClass / labelAttributes |
Extra class/attributes on the label |
headingPrefix / headingSuffix |
Raw HTML injected inside .heading |
labelExtra |
Raw HTML after the .flex-grow spacer |
actionMenuItems |
Items for a ⋯ disclosure menu in the heading |
showAttribute |
Shows the field handle as a copytext button (admins with the pref on) |
first |
Adds .first — drops the top margin |
disabled |
Adds .disabled to .input |
Fieldsets¶
When a field wraps more than one labelled control — a date and a time, a group of
checkboxes — a <label for> can't honestly point at one input. Pass fieldset: true and Craft
switches to <legend> with role="group" and aria-labelledby:
<div id="expiry-field" class="field" role="group" aria-labelledby="expiry-label" data-attribute="expiry">
<div class="heading"><legend id="expiry-label">Expiry</legend></div>
…
</div>
dateTimeField and checkboxGroupField set this for you. You
only pass it by hand when building a custom multi-control field.
Errors¶
- Error: Handle must contain only letters, numbers and underscores.
Errors put .has-errors on the .field and .errors on .input, then append a
<ul class="errors">. Each <li> gets a visually-hidden "Error:" prefix.
Pass them straight from the model — errors: entry.getErrors("slug") — and the ids line up with
aria-describedby automatically.
In the heading¶
Anything beyond the label goes after a .flex-grow spacer, which pushes it to the trailing edge:
<div class="heading">
<legend id="fields-newsCategory-label">News Category</legend>
<div class="flex-grow"></div>
<button type="button" class="btn menubtn action-btn small prevent-autofocus"
aria-controls="fields-menu-978975904" aria-label="Actions"
data-disclosure-trigger="true" aria-expanded="false"></button>
</div>
That spacer only renders when there's something to push — an actionMenuItems menu, a
showAttribute handle button, or your own labelExtra.
Note
From PHP. Twig's forms.field(config, input) is a thin wrapper over
craft\helpers\Cp::fieldHtml($input, $config) — same config keys, same output. Every
*Field macro routes through it, which is why they all share the table above. Building a
field type's settings, or returning HTML from a controller? Call Cp::textFieldHtml() and
friends directly instead of rendering Twig.
Note
Two flavors of most macros. A bare input macro (e.g. forms.text) renders just the
control. The *Field variant (e.g. forms.textField) wraps that control in this shell.
Reach for *Field inside settings screens; use the bare input where you supply your own
labelling. See form macros.