Creating custom Twig templates for form elementsfor Drupal 8 , 9 , 10 , and 11

Last updated :  

A render array knows what a form element *is* — a textfield, a submit button — but it doesn't decide what that element *looks like* on the page. That job belongs entirely to Twig. Think of it like a furniture factory: the Form API builds the frame and the mechanism (the actual `<input>`, its name, its value), and Twig is the upholsterer that decides the fabric, the stitching, the finish. Same frame, completely different look, depending on which template gets applied. This lesson is about taking control of that upholstery.

What you'll learn in this lesson

  • How Drupal maps a form element's `#type` to a specific Twig template file
  • How to override an individual element's markup with a custom `input--TYPE.html.twig`
  • How `hook_preprocess_HOOK()` lets you inject conditional markup into an element's template without touching the render array's structure
  • Why every theme change here needs a cache rebuild before you'll see it

Overriding a single element type

Every form element render array eventually passes through a theme hook — for a text field, that's `input.html.twig` (or, more specifically, `input--textfield.html.twig` if your theme provides one). Drupal's template discovery always prefers the most specific matching filename, so adding a new file is enough; there's no PHP registration step required for this particular override.

{# themes/custom/mytheme/templates/input--textfield.html.twig #}
{%
  set classes = [
    'form-control',
    'custom-textfield',
  ]
%}
<input{{ attributes.addClass(classes) }}>

That single added class is now available to target in your theme's CSS, without touching a single line of the module or form-building PHP that defined the field in the first place — the separation between "what the field does" and "how the field looks" stays completely intact.

Conditionally styling a label with a preprocess hook

Sometimes you don't want to override an entire template just to tweak one detail — you want to inject a bit of conditional logic instead. The Drupal Examples project's theming_example module does exactly this for form element labels:

/**
 * Implements hook_preprocess_HOOK().
 */
function theming_example_preprocess_form_element_label(&$variables) {
  if (!empty($variables['element']['#attributes']['data-strong'])) {
    $variables['title']['#prefix'] = '<strong>';
    $variables['title']['#suffix'] = '</strong>';
    unset($variables['#attributes']['data-strong']);
  }
}

The corresponding hook_form_alter() sets a plain flag on the element rather than hardcoding any markup:

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 the division of labor: the form-alter code only ever sets a boolean-ish flag — it has no idea that flag means "wrap in <strong>." The preprocess function owns that decision entirely. This is the cleanest way to keep presentation logic out of your form-building code: the form just describes intent, and the theme layer decides how that intent gets rendered.

Theming a whole form via a registered theme hook

For more control than element-by-element overrides give you, a module can register its own theme hook for an entire form and point it at a dedicated template:

function theming_example_theme($existing, $type, $theme, $path) {
  return [
    'theming_example_text_form' => [
      'render element' => 'form',
      // In this one the rendering will be done by a template file
      // (theming-example-text-form.html.twig) instead of being rendered by a
      // theme function.
      'template' => 'theming-example-text-form',
    ],
  ];
}

The matching template receives the whole form as a pre-rendered variable, plus a keyed array of its individual elements — giving you the choice of printing the entire form as-is, or picking elements out one at a time to arrange them exactly how you want:

{#
  Available custom variables:
  - text_form: A string containing the pre-rendered form.
  - text_form_content: An array of form elements keyed by the element name.
#}
<div class="container-inline">
{{ text_form }}
</div>
Always rebuild the cache after a theming change. Twig templates and theme-hook registrations are both cached aggressively for performance. Add a new input--textfield.html.twig or change a hook_theme() array and nothing will visibly change until you run drush cr (or clear cache from Configuration → Performance) — a near-universal first troubleshooting step whenever a theme override "isn't working."

Quick check: you want every textfield across your entire site to get a new CSS class, with zero exceptions. Do you reach for a preprocess hook on a specific form, or a template override? A template override (input--textfield.html.twig) is correct here — it's the broadest, simplest tool for a change that applies universally. Save preprocess hooks for logic that depends on a specific condition, like the data-strong flag above.

Key takeaways

  • Every render array element passes through a Twig template resolved by #type — override input--TYPE.html.twig in your theme to restyle one element type site-wide, no PHP required.
  • Use hook_preprocess_HOOK() when a template change should only apply conditionally — it lets a form-alter set a simple flag while the preprocess function owns the actual markup decision.
  • A module can register a fully custom theme hook via hook_theme() and point it at its own template file for complete control over one specific form's markup.
  • A template variable like text_form_content being keyed by element name lets you rearrange a form's pieces freely in the template, instead of printing the whole form as one opaque block.
  • Nothing you change here takes effect until the cache is rebuilt — check that first whenever a theming change appears to do nothing.

Coming up next

Overriding one element type at a time is powerful, but sometimes you want to redesign an entire specific form from top to bottom without affecting any other form on the site. The next lesson covers exactly that: using form--[form-id].html.twig to target one form, and only that form, by its unique ID.