Drupal 11's theming system uses Twig 3, a modern templating engine that keeps logic out of templates and makes overriding core HTML straightforward. The recommended starting point is the Starterkit — a generator that scaffolds a fully functional sub-theme from Drupal core's stable base, so you own all the HTML from day one without inheriting any styles you did not choose.
This guide builds a production-ready custom theme: Starterkit generation, .info.yml anatomy, CSS/JS library system, template overrides with Twig, preprocess hooks, and common patterns for Drupal 11 field rendering.
Generate the Theme with Starterkit
# From the Drupal root (one level above /web)
php web/core/scripts/drupal generate-theme \
--starterkit starterkit_theme \
--name "My Site" \
--machine-name my_site \
web/themes/custom/my_site
This creates:
web/themes/custom/my_site/
├── my_site.info.yml
├── my_site.libraries.yml
├── my_site.theme ← PHP preprocess hooks
├── logo.svg
├── favicon.ico
├── css/
│ └── base/
│ └── base.css
├── js/
│ └── my_site.js
├── images/
└── templates/
├── block/
├── content/
├── field/
├── form/
├── layout/
├── misc/
├── navigation/
├── page/
└── views/
Enable the theme:
drush theme:enable my_site -y
drush config:set system.theme default my_site -y
drush cr
The .info.yml File
# my_site.info.yml
name: 'My Site'
type: theme
description: 'Custom Drupal 11 theme for My Site.'
package: 'Custom'
core_version_requirement: ^11
# Starterkit does not use a base_theme — it owns all templates
base_theme: false
# Libraries loaded on every page
libraries:
- my_site/global-styles
- my_site/global-scripts
# Libraries loaded conditionally can be attached in templates or preprocess hooks
# libraries-override and libraries-extend modify contrib/core libraries
libraries-override:
claro/base: false # Remove admin theme base library if admin on same theme
# Regions define the structural slots available in page.html.twig
regions:
header: 'Header'
primary_menu: 'Primary navigation'
breadcrumb: 'Breadcrumb'
highlighted: 'Highlighted'
content: 'Content'
sidebar: 'Sidebar'
footer: 'Footer'
The Library System (.libraries.yml)
# my_site.libraries.yml
global-styles:
css:
theme:
css/base/base.css: {}
css/layout/layout.css: {}
css/components/components.css: {}
global-scripts:
js:
js/my_site.js: {}
dependencies:
- core/drupal
- core/once
# A component library (attached conditionally)
image-gallery:
css:
component:
css/components/gallery.css: {}
js:
js/gallery.js: {}
dependencies:
- core/drupal
Attach a conditional library in a template:
{# In a Twig template that needs the gallery component #}
{{ attach_library('my_site/image-gallery') }}
Or from a preprocess hook:
// my_site.theme
function my_site_preprocess_node(&$variables) {
$node = $variables['node'];
if ($node->bundle() === 'gallery') {
$variables['#attached']['library'][] = 'my_site/image-gallery';
}
}
Template Overrides: Naming Convention
Drupal selects templates using a candidate-file algorithm. Templates with more specific names take priority. Common patterns:
| Template file | Applies to |
|---|---|
page.html.twig | All pages |
page--front.html.twig | Front page only |
node.html.twig | All nodes |
node--article.html.twig | Nodes of type article |
node--article--full.html.twig | Article nodes in full view mode |
block.html.twig | All blocks |
block--system-branding-block.html.twig | The site branding block |
field--field-image.html.twig | The field_image field on all entities |
views-view--my-view.html.twig | A specific Views view |
Copy the source template from core, then modify it — never edit core files directly:
# Find where a template lives in core
find web/core -name "node.html.twig" 2>/dev/null
# Copy to your theme's templates directory
cp web/core/themes/starterkit_theme/templates/content/node.html.twig \
web/themes/custom/my_site/templates/content/node.html.twig
drush cr # Clear cache so Drupal picks up the new template
page.html.twig: Building Your Layout
{# templates/layout/page.html.twig #}
<div class="layout-container">
<header class="site-header" role="banner">
{{ page.header }}
<nav class="site-nav" aria-label="{{ 'Main navigation'|t }}">
{{ page.primary_menu }}
</nav>
</header>
{% if page.breadcrumb %}
<nav class="breadcrumb" aria-label="{{ 'Breadcrumb'|t }}">
{{ page.breadcrumb }}
</nav>
{% endif %}
<main role="main" id="main-content">
<a href="#main-content" class="visually-hidden focusable skip-link">
{{ 'Skip to main content'|t }}
</a>
{% if page.highlighted %}
<div class="highlighted">{{ page.highlighted }}</div>
{% endif %}
<div class="layout-main-wrapper">
<div class="layout-main">{{ page.content }}</div>
{% if page.sidebar %}
<aside class="layout-sidebar">{{ page.sidebar }}</aside>
{% endif %}
</div>
</main>
<footer class="site-footer">
{{ page.footer }}
</footer>
</div>
node--article--full.html.twig
{# templates/content/node--article--full.html.twig #}
{{ attach_library('my_site/article-styles') }}
<article{{ attributes.addClass('article', 'article--full') }}>
<header class="article__header">
{{ title_prefix }}
<h1{{ title_attributes.addClass('article__title') }}>
{{ label }}
</h1>
{{ title_suffix }}
<div class="article__meta">
<time datetime="{{ node.created.value|date('Y-m-d') }}">
{{ date_formatted }}
</time>
{% if display_submitted %}
<span class="article__author">{{ 'by'|t }} {{ author_name }}</span>
{% endif %}
</div>
</header>
{% if content.field_image %}
<div class="article__hero">
{{ content.field_image }}
</div>
{% endif %}
<div{{ content_attributes.addClass('article__body') }}>
{{ content.body }}
</div>
{% if content.field_tags %}
<footer class="article__footer">
<div class="article__tags">{{ content.field_tags }}</div>
</footer>
{% endif %}
</article>
Preprocess Hooks in my_site.theme
<?php
// my_site.theme
/**
* Implements hook_preprocess_HOOK() for html templates.
*/
function my_site_preprocess_html(&$variables) {
// Add a body class for each route
$route_name = \Drupal::routeMatch()->getRouteName();
$variables['attributes']['class'][] = 'route--' . str_replace('.', '-', $route_name);
// Add viewport meta tag
$variables['#attached']['html_head'][] = [
[
'#tag' => 'meta',
'#attributes' => [
'name' => 'viewport',
'content' => 'width=device-width, initial-scale=1',
],
],
'viewport',
];
}
/**
* Implements hook_preprocess_HOOK() for node templates.
*/
function my_site_preprocess_node(&$variables) {
$node = $variables['node'];
// Make the formatted creation date available as a simple variable
$variables['date_formatted'] = \Drupal::service('date.formatter')
->format($node->getCreatedTime(), 'custom', 'F j, Y');
// Add reading time estimate to articles
if ($node->bundle() === 'article' && $node->hasField('body')) {
$body = $node->get('body')->value ?? '';
$word_count = str_word_count(strip_tags($body));
$variables['reading_time'] = (int) ceil($word_count / 200); // 200 wpm average
}
}
/**
* Implements hook_preprocess_HOOK() for block templates.
*/
function my_site_preprocess_block(&$variables) {
// Remove contextual links from specific blocks on the front end
if (isset($variables['elements']['#id'])
&& $variables['elements']['#id'] === 'my_site_main_menu') {
unset($variables['elements']['#contextual_links']);
}
}
Twig Best Practices for Drupal 11
Use content variables, not field API calls
{# Wrong: bypasses field formatters and display settings #}
{{ node.field_image.entity.uri.value }}
{# Correct: renders through the configured formatter #}
{{ content.field_image }}
Conditional output without empty checks
{# Drupal render arrays need the empty() check OR the Twig empty filter #}
{% if content.field_tags|render %}
<div class="tags">{{ content.field_tags }}</div>
{% endif %}
Translation and pluralisation
{# Single string #}
{{ 'Published'|t }}
{# With replacement #}
{{ 'Published by @name'|t({'@name': author_name}) }}
{# Plural #}
{{ reading_time }} {{ 'min read'|t }}
Debugging templates — find which template is used
# Enable Twig debugging in development.services.yml
parameters:
twig.config:
debug: true
auto_reload: true
cache: false
With Twig debug enabled, HTML source comments show the template candidates and the winner for each component — invaluable when creating overrides.
Summary Checklist
- Generate your theme with
php core/scripts/drupal generate-themefrom the Starterkit — do not start from scratch. - Set
base_theme: false— you own all templates, no inherited styles to fight. - Declare all CSS and JS in
.libraries.yml; never use<link>or<script>tags directly in templates. - Use specific template names (e.g.
node--article--full.html.twig) to target only the context you need. - Copy templates from core before modifying them; always
drush crafter adding a new template. - Use preprocess hooks for logic; keep templates pure output with minimal conditions.
- Use
{{ content.field_name }}not raw field values — it respects formatters and display settings. - Enable Twig debug in development to see which template file is rendering each component.
- Use
{{ content.field_tags|render }}as the condition check for render arrays rather than bare{% if content.field_tags %}.