Using #attributes for styling formsfor Drupal 8 , 9 , 10 , and 11

Last updated :  

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.

Always set 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 — #attributes or #label_attributes? #label_attributes. #attributes only 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

  • #attributes sets 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_attributes is the separate key for the element's <label> tag — #attributes never 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 #attributes before 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.