Not every styling need justifies a whole new Twig file. If all you want is a CSS class on one field, or a data-* attribute a JavaScript behavior can hook into, creating a template override is like hiring a carpenter to hang a picture frame — technically it'll work, but there's a much smaller tool for the job. That tool is #attributes: a plain array, set directly on the render array, that becomes real HTML attributes on the rendered element with zero Twig involved.
What you'll learn in this lesson
- How to add a CSS class to one form element via
#attributes - The distinction between
#attributes(the element itself) and#label_attributes(its label) - How to attach a
data-*attribute for a JavaScript behavior to read - Why this approach requires no template file and no cache-sensitive theme discovery at all
Adding a class to a single element
Set #attributes['class'] as an array (not a string) — Drupal merges it correctly with any classes core itself adds:
$form['email'] = [
'#type' => 'email',
'#title' => $this->t('Email address'),
'#attributes' => [
'class' => ['contact-form-email'],
],
];
That's the entire change — no template file, no hook_theme() registration, nothing to clear from a template cache. The class appears directly on the rendered <input> tag the moment the form is built, which makes #attributes the right first choice whenever a template override would be overkill.
Targeting the label separately with #label_attributes
A field's label is a separate piece of markup from the field itself, so #attributes alone won't touch it — you need #label_attributes. The Drupal Examples project's theming_example module uses exactly this to conditionally bold a label:
case 'theming_example_form_text':
// Add data-strong attribute to make title strong.
// @see theming_example_preprocess_form_element_label().
$form['text']['#label_attributes']['data-strong'] = 1;
break;
Notice this isn't setting a CSS class at all — it's setting a bare data-strong attribute with no styling meaning of its own. A paired hook_preprocess_form_element_label() elsewhere in the module reads that flag and decides what to actually do about it (wrap the title in <strong> tags). This is a genuinely useful pattern: #attributes/#label_attributes can carry a plain signal, and a preprocess function or a client-side JavaScript behavior can be the thing that acts on it — keeping the form-building code itself free of presentation decisions.
Using a data attribute as a JavaScript hook
The same mechanism works for attaching data a Drupal.behaviors script can read, completely independent of any styling:
$form['phone'] = [
'#type' => 'tel',
'#title' => $this->t('Phone number'),
'#attributes' => [
'data-mask' => 'phone-us',
],
];
A JavaScript behavior can then select every element with [data-mask="phone-us"] and attach an input-masking library to it, without the JavaScript ever needing to know anything about how the form itself is built — the render array is the single source of truth for which elements opt in.
class as an array, never a plain string. 'class' => 'my-class' technically renders, but it silently prevents Drupal core from safely merging in its own classes for that element type — you can end up losing default classes you didn't intend to remove. 'class' => ['my-class'] is the only form that merges correctly.Quick check: you need to add a CSS class to a field's label, not the field itself. Which key do you use —
#attributesor#label_attributes?#label_attributes.#attributesonly ever touches the element being labeled (the input, select, textarea, etc.) — the<label>tag is rendered separately and needs its own attributes array entirely.
Key takeaways
#attributessets real HTML attributes directly on a render array element — no template file, no theme-hook registration, no cache-sensitive discovery step.- Always set
'class'as an array (['my-class']), never a bare string, so Drupal can merge it safely with any classes core already adds. #label_attributesis the separate key for the element's<label>tag —#attributesnever touches it.- A plain
data-*attribute can carry a signal for a preprocess function or a JavaScript behavior to act on, keeping the form-building code itself free of presentation logic. - Reach for
#attributesbefore reaching for a template override whenever the change is a simple class or data attribute on one element — save full template overrides for genuine structural changes.
Coming up next
So far every styling technique has assumed the form is valid. The next lesson covers the other side: how Drupal actually renders validation error messages, and how to style them distinctly from the rest of your form.