13 KiB
13 KiB
Drupal Theming Reference
Comprehensive guide to Drupal theming for Drupal 8-11+.
Theme Structure
mytheme/
├── mytheme.info.yml # Theme metadata (required)
├── mytheme.libraries.yml # CSS/JS libraries
├── mytheme.theme # Theme functions and preprocess
├── mytheme.breakpoints.yml # Responsive breakpoints
├── logo.svg # Theme logo
├── screenshot.png # Admin screenshot
├── composer.json # Composer dependencies
├── package.json # NPM dependencies (if using build tools)
├── config/
│ ├── install/ # Default configuration
│ └── schema/ # Configuration schema
├── css/
│ ├── base/
│ ├── components/
│ ├── layout/
│ └── theme/
├── js/
│ └── custom.js
├── images/
├── templates/
│ ├── block/
│ ├── content/
│ ├── field/
│ ├── layout/
│ ├── navigation/
│ ├── views/
│ ├── page.html.twig
│ ├── node.html.twig
│ └── block.html.twig
└── src/ # Optional PHP classes
└── Plugin/
└── Preprocess/
Theme Info File (mytheme.info.yml)
name: My Theme
type: theme
description: 'A custom Drupal theme'
core_version_requirement: ^9 || ^10 || ^11
package: Custom
# Base theme
base theme: stable9
# Or for no base theme:
# base theme: false
# Regions
regions:
header: Header
primary_menu: 'Primary menu'
secondary_menu: 'Secondary menu'
page_top: 'Page top'
page_bottom: 'Page bottom'
highlighted: Highlighted
breadcrumb: Breadcrumb
content: Content
sidebar_first: 'Sidebar first'
sidebar_second: 'Sidebar second'
footer: Footer
# Libraries to load on all pages
libraries:
- mytheme/global-styling
- mytheme/global-scripts
# Libraries to load only when certain conditions are met
libraries-override:
# Replace core library
core/drupal.dialog:
mytheme/custom-dialog: {}
libraries-extend:
# Add to existing library
core/drupal.dialog:
- mytheme/dialog-extend
# Remove libraries
libraries-override:
core/normalize:
css:
base:
assets/vendor/normalize-css/normalize.css: false
# Component libraries (for single-directory components)
component-libraries:
atoms:
paths:
- components/atoms
molecules:
paths:
- components/molecules
# Logo and favicon
logo: images/logo.svg
favicon: images/favicon.ico
# Stylesheets to remove
stylesheets-remove:
- core/assets/vendor/normalize-css/normalize.css
- '@classy/css/components/tabs.css'
# CKEditor stylesheet
ckeditor_stylesheets:
- css/ckeditor.css
# Hidden theme (for base themes)
hidden: false
Libraries (mytheme.libraries.yml)
global-styling:
version: 1.0
css:
base:
css/base/reset.css: {}
layout:
css/layout/layout.css: {}
component:
css/components/button.css: {}
css/components/card.css: {}
theme:
css/theme/colors.css: {}
css/theme/typography.css: {}
global-scripts:
version: 1.0
js:
js/custom.js: {}
dependencies:
- core/drupal
- core/jquery
# Conditional library (loaded via preprocess or template)
modal:
version: 1.0
css:
component:
css/components/modal.css: {}
js:
js/modal.js: {}
dependencies:
- core/drupal
- core/drupalSettings
# External library
fontawesome:
version: 6.0
css:
theme:
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css: { type: external, minified: true }
# SCSS/SASS (requires compilation)
compiled-styles:
version: 1.0
css:
theme:
dist/css/styles.css: {}
dependencies:
- core/normalize
Theme Functions (mytheme.theme)
<?php
/**
* @file
* Functions to support theming in the My Theme theme.
*/
use Drupal\Core\Form\FormStateInterface;
use Drupal\node\NodeInterface;
/**
* Implements hook_preprocess_HOOK() for page templates.
*/
function mytheme_preprocess_page(&$variables) {
// Add custom variable to all pages
$variables['site_slogan'] = \Drupal::config('system.site')->get('slogan');
// Add body classes
$variables['attributes']['class'][] = 'custom-page-class';
}
/**
* Implements hook_preprocess_HOOK() for node templates.
*/
function mytheme_preprocess_node(&$variables) {
/** @var \Drupal\node\NodeInterface $node */
$node = $variables['node'];
// Add custom variables
$variables['created_date'] = \Drupal::service('date.formatter')->format(
$node->getCreatedTime(),
'custom',
'F j, Y'
);
// Add template suggestions
$variables['theme_hook_suggestions'][] = 'node__' . $node->bundle() . '__' . $variables['view_mode'];
}
/**
* Implements hook_preprocess_HOOK() for block templates.
*/
function mytheme_preprocess_block(&$variables) {
// Add block ID as class
if (isset($variables['elements']['#id'])) {
$variables['attributes']['class'][] = 'block-' . $variables['elements']['#id'];
}
}
/**
* Implements hook_preprocess_HOOK() for field templates.
*/
function mytheme_preprocess_field(&$variables) {
$element = $variables['element'];
// Custom preprocessing for specific fields
if ($element['#field_name'] == 'field_custom') {
$variables['custom_class'] = 'field-custom-class';
}
}
/**
* Implements hook_theme_suggestions_HOOK_alter() for page templates.
*/
function mytheme_theme_suggestions_page_alter(array &$suggestions, array $variables) {
// Add template suggestions based on current route
if ($node = \Drupal::routeMatch()->getParameter('node')) {
if ($node instanceof NodeInterface) {
$suggestions[] = 'page__node__' . $node->bundle();
$suggestions[] = 'page__node__' . $node->id();
}
}
}
/**
* Implements hook_theme_suggestions_HOOK_alter() for node templates.
*/
function mytheme_theme_suggestions_node_alter(array &$suggestions, array $variables) {
/** @var \Drupal\node\NodeInterface $node */
$node = $variables['elements']['#node'];
$view_mode = $variables['elements']['#view_mode'];
// Add suggestion for bundle and view mode combination
$suggestions[] = 'node__' . $node->bundle() . '__' . $view_mode;
}
/**
* Implements hook_form_alter().
*/
function mytheme_form_alter(&$form, FormStateInterface $form_state, $form_id) {
// Add custom classes to forms
if ($form_id == 'search_block_form') {
$form['actions']['submit']['#attributes']['class'][] = 'custom-search-submit';
}
}
/**
* Implements hook_page_attachments_alter().
*/
function mytheme_page_attachments_alter(array &$attachments) {
// Add custom meta tags
$meta_charset = [
'#tag' => 'meta',
'#attributes' => [
'charset' => 'utf-8',
],
];
$attachments['#attached']['html_head'][] = [$meta_charset, 'meta_charset'];
}
/**
* Implements hook_library_info_alter().
*/
function mytheme_library_info_alter(&$libraries, $extension) {
// Modify libraries from other modules
if ($extension == 'core' && isset($libraries['drupal.dialog'])) {
$libraries['drupal.dialog']['dependencies'][] = 'mytheme/custom-dialog';
}
}
Twig Templates
page.html.twig
<div class="layout-container">
<header role="banner">
{{ page.header }}
{{ page.primary_menu }}
</header>
{{ page.breadcrumb }}
{{ page.highlighted }}
<main role="main" class="main-content">
<a id="main-content" tabindex="-1"></a>
<div class="layout-content">
{{ page.content }}
</div>
{% if page.sidebar_first %}
<aside class="sidebar sidebar--first" role="complementary">
{{ page.sidebar_first }}
</aside>
{% endif %}
{% if page.sidebar_second %}
<aside class="sidebar sidebar--second" role="complementary">
{{ page.sidebar_second }}
</aside>
{% endif %}
</main>
{% if page.footer %}
<footer role="contentinfo">
{{ page.footer }}
</footer>
{% endif %}
</div>
node.html.twig
<article{{ attributes.addClass('node', 'node--type-' ~ node.bundle|clean_class, node.isPromoted() ? 'node--promoted', node.isSticky() ? 'node--sticky', not node.isPublished() ? 'node--unpublished', view_mode ? 'node--view-mode-' ~ view_mode|clean_class) }}>
{{ title_prefix }}
{% if not page %}
<h2{{ title_attributes.addClass('node__title') }}>
<a href="{{ url }}" rel="bookmark">{{ label }}</a>
</h2>
{% endif %}
{{ title_suffix }}
{% if display_submitted %}
<div class="node__meta">
{{ author_picture }}
<span{{ author_attributes }}>
{% trans %}Submitted by {{ author_name }} on {{ date }}{% endtrans %}
</span>
{{ metadata }}
</div>
{% endif %}
<div{{ content_attributes.addClass('node__content') }}>
{{ content }}
</div>
</article>
block.html.twig
<div{{ attributes.addClass('block', 'block-' ~ configuration.provider|clean_class, 'block-' ~ plugin_id|clean_class) }}>
{{ title_prefix }}
{% if label %}
<h2{{ title_attributes }}>{{ label }}</h2>
{% endif %}
{{ title_suffix }}
{% block content %}
{{ content }}
{% endblock %}
</div>
field.html.twig
{% if multiple %}
<div{{ attributes.addClass('field', 'field--name-' ~ field_name|clean_class, 'field--type-' ~ field_type|clean_class) }}>
{% if label %}
<div{{ title_attributes.addClass('field__label') }}>{{ label }}</div>
{% endif %}
<div class="field__items">
{% for item in items %}
<div{{ item.attributes.addClass('field__item') }}>{{ item.content }}</div>
{% endfor %}
</div>
</div>
{% else %}
{% for item in items %}
<div{{ attributes.addClass('field', 'field--name-' ~ field_name|clean_class, 'field--type-' ~ field_type|clean_class) }}>
{% if label %}
<div{{ title_attributes.addClass('field__label') }}>{{ label }}</div>
{% endif %}
<div{{ item.attributes.addClass('field__item') }}>{{ item.content }}</div>
</div>
{% endfor %}
{% endif %}
Twig Filters & Functions
Common Filters
{# Translate #}
{{ 'Hello World'|t }}
{# Clean class name #}
{{ 'Field Name'|clean_class }}
{# Format date #}
{{ node.created.value|date('F j, Y') }}
{# Safe join #}
{{ items|safe_join(', ') }}
{# Render #}
{{ content|render }}
{# Without (remove array elements) #}
{{ content|without('field_image') }}
{# Placeholder #}
{{ 'Hello @name'|t({'@name': name}) }}
Common Functions
{# Attach library #}
{{ attach_library('mytheme/modal') }}
{# URL #}
<a href="{{ url('entity.node.canonical', {'node': node.id}) }}">Link</a>
{# Path #}
<a href="{{ path('entity.node.canonical', {'node': node.id}) }}">Link</a>
{# File URL #}
{{ file_url('public://image.jpg') }}
{# Create attribute #}
{% set attributes = create_attribute() %}
{{ attributes.addClass('custom-class') }}
Breakpoints (mytheme.breakpoints.yml)
mytheme.mobile:
label: Mobile
mediaQuery: 'screen and (min-width: 0px)'
weight: 0
multipliers:
- 1x
- 2x
mytheme.tablet:
label: Tablet
mediaQuery: 'screen and (min-width: 768px)'
weight: 1
multipliers:
- 1x
- 2x
mytheme.desktop:
label: Desktop
mediaQuery: 'screen and (min-width: 1024px)'
weight: 2
multipliers:
- 1x
- 2x
mytheme.wide:
label: Wide
mediaQuery: 'screen and (min-width: 1440px)'
weight: 3
multipliers:
- 1x
Debug Mode
Enable Twig debugging in development:
sites/default/services.yml
parameters:
twig.config:
debug: true
auto_reload: true
cache: false
Then clear cache:
ddev drush cr
Theme Development Workflow
-
Enable development settings
# Copy and enable development settings cp sites/example.settings.local.php sites/default/settings.local.php -
Disable CSS/JS aggregation
- Go to
/admin/config/development/performance - Uncheck "Aggregate CSS files"
- Uncheck "Aggregate JavaScript files"
- Go to
-
Clear cache frequently
ddev drush cr -
Use Twig debugging
- Check HTML source for template suggestions
- Look for
<!-- BEGIN OUTPUT -->comments
-
Rebuild theme registry
ddev drush drush cr
Best Practices
- BEM Methodology: Use BEM for CSS class naming
- Component-based: Build reusable components
- Accessibility: Follow WCAG guidelines
- Performance: Optimize images, minimize CSS/JS
- Mobile-first: Design for mobile, enhance for desktop
- Semantic HTML: Use proper HTML5 elements
- Template suggestions: Use specific templates when needed
- Libraries: Group related CSS/JS in libraries
- Preprocessing: Keep logic in .theme file, not templates
- Documentation: Comment complex template logic
Common Template Suggestions
page--front.html.twig # Front page
page--node--123.html.twig # Specific node
page--node--article.html.twig # Content type
node--article--full.html.twig # Bundle and view mode
node--123.html.twig # Specific node
block--system-branding-block.html.twig # Specific block
field--node--title--article.html.twig # Specific field
views-view--blog--page-1.html.twig # View and display
Useful Resources
- Theming Guide: https://www.drupal.org/docs/theming-drupal
- Twig Documentation: https://twig.symfony.com/doc/
- Template Suggestions: https://www.drupal.org/docs/theming-drupal/twig-in-drupal/debugging-twig-templates