Developing Custom Drupal Themes
Drupal themes are a set of Twig templates, CSS, JS, and YAML configuration files. Development from scratch or based on a base theme. Contrib themes like Bootstrap or Gin are used as starting points or for the admin interface.
Theme Structure
web/themes/custom/my_theme/
├── my_theme.info.yml # theme description
├── my_theme.libraries.yml # CSS/JS libraries
├── my_theme.theme # PHP theme hooks
├── config/
│ └── install/ # config installed with theme
├── css/
│ ├── base.css
│ ├── layout.css
│ └── components/
├── js/
│ └── main.js
├── images/
├── fonts/
├── templates/
│ ├── layout/
│ │ ├── html.html.twig
│ │ └── page.html.twig
│ ├── content/
│ │ ├── node.html.twig
│ │ ├── node--article.html.twig
│ │ └── node--article--teaser.html.twig
│ ├── block/
│ │ └── block.html.twig
│ └── field/
│ └── field--body.html.twig
└── screenshot.png
my_theme.info.yml
name: My Theme
type: theme
description: 'Custom project theme'
core_version_requirement: ^10
base theme: stable9 # or false if from scratch
libraries:
- my_theme/global
regions:
header: Header
primary_menu: 'Primary menu'
breadcrumb: Breadcrumb
highlighted: Highlighted
content: Content
sidebar_first: 'Sidebar first'
footer: Footer
CSS/JS Libraries
# my_theme.libraries.yml
global:
version: VERSION
css:
base:
css/base.css: {}
layout:
css/layout.css: {}
css/components/header.css: {}
js:
js/main.js: { defer: true }
dependencies:
- core/drupal
- core/jquery
# Separate library for slider — loaded only when needed
slider:
version: VERSION
css:
component:
css/components/slider.css: {}
js:
js/slider.js: {}
dependencies:
- core/once
Attach libraries in template:
{# In node--landing.html.twig #}
{{ attach_library('my_theme/slider') }}
Twig Templates
Drupal uses template hierarchy — the more specific the name, the higher the priority:
node.html.twig # all nodes
node--article.html.twig # article type
node--article--full.html.twig # article type, full view
node--123.html.twig # specific node by id
Example of overriding article template:
{# templates/content/node--article--full.html.twig #}
<article{{ attributes.addClass('article', 'article--full') }}>
{% if label %}
<h1{{ title_attributes.addClass('article__title') }}>
{{ label }}
</h1>
{% endif %}
<div class="article__meta">
{% if display_submitted %}
<span class="article__author">{{ author_name }}</span>
<time class="article__date" datetime="{{ date.attributes.datetime }}">
{{ date }}
</time>
{% endif %}
{% if content.field_tags %}
<div class="article__tags">
{{ content.field_tags }}
</div>
{% endif %}
</div>
{% if content.field_image %}
<div class="article__cover">
{{ content.field_image }}
</div>
{% endif %}
<div class="article__body prose">
{{ content.body }}
</div>
{# Render remaining fields except those already output #}
{{ content|without('body', 'field_tags', 'field_image', 'links') }}
</article>
PHP Hooks in .theme File
// my_theme.theme
/**
* Add variables to page template.
*/
function my_theme_preprocess_page(array &$variables): void {
$variables['site_name'] = \Drupal::config('system.site')->get('name');
$variables['is_front'] = \Drupal::service('path.matcher')->isFrontPage();
// Breadcrumbs with custom logic
$route = \Drupal::routeMatch();
if ($node = $route->getParameter('node')) {
$variables['node_type'] = $node->bundle();
}
}
/**
* Variables for node template.
*/
function my_theme_preprocess_node(array &$variables): void {
$node = $variables['node'];
if ($node->bundle() === 'article') {
$variables['reading_time'] = my_theme_calculate_reading_time($node->get('body')->value);
}
}
function my_theme_calculate_reading_time(string $html): int {
$text = strip_tags($html);
$words = str_word_count($text);
return (int) ceil($words / 200); // 200 words per minute
}
/**
* Override template suggestions — for debugging.
*/
function my_theme_theme_suggestions_node_alter(array &$suggestions, array $variables): void {
$node = $variables['elements']['#node'];
$view_mode = $variables['elements']['#view_mode'];
// Add suggestion by material type field
if ($node->hasField('field_material_type') && !$node->get('field_material_type')->isEmpty()) {
$type = $node->get('field_material_type')->value;
$suggestions[] = 'node__' . $node->bundle() . '__' . $type;
}
}
/**
* Form alter — add classes.
*/
function my_theme_form_alter(array &$form, FormStateInterface $form_state, string $form_id): void {
if ($form_id === 'contact_message_feedback_form') {
$form['#attributes']['class'][] = 'contact-form';
$form['actions']['submit']['#attributes']['class'][] = 'btn btn--primary';
}
}
Responsive Images and Image Styles
# config/install/image.style.article_cover.yml
langcode: en
status: true
id: article_cover
label: 'Article cover'
effects:
uuid1:
id: image_scale_and_crop
data:
anchor: center-center
width: 1200
height: 630
Use responsive_image instead of plain image in template:
{% if content.field_image %}
{{- content.field_image -}}
{% endif %}
Responsive image group is configured in /admin/config/media/responsive-image-style via UI or YAML.
Frontend Build (optional)
If theme uses npm build:
// package.json
{
"scripts": {
"build": "postcss css/src -o css --map",
"watch": "postcss css/src -o css --watch"
}
}
Or Vite/Webpack if you need ES modules, TypeScript:
// vite.config.js
export default {
build: {
outDir: 'dist',
rollupOptions: {
input: { main: 'js/src/main.ts' },
output: { entryFileNames: 'js/[name].js' }
}
},
css: { postcss: './postcss.config.js' }
}
Debug Templates
In settings.local.php enable Twig debug:
$config['system.performance']['css']['preprocess'] = FALSE;
$config['system.performance']['js']['preprocess'] = FALSE;
$settings['cache']['bins']['render'] = 'cache.backend.null';
// Shows template names in HTML comments
$config['twig.settings']['debug'] = TRUE;
After enabling, page source will show all used templates and available suggestions. This is the main tool when developing themes.
Timelines
Base theme with templates for main content types, responsive, with libraries: 5–8 days. With animations, complex JS, responsive images, custom blocks: 10–15 days.







