A Drupal theme can look finished and still fail. The CSS is polished. The design matches Figma. Then you turn on a screen reader and hear a wall of nested divs.

We've built themes from scratch and inherited a few we'd rather forget. Same story each time. Accessibility gets decided in the .html.twig file, long before a module or an audit tool enters the picture.

Twig templating paired with BEM methodology changes how theming gets approached entirely. Accessible Drupal theming means writing .html.twig templates with semantic HTML5 and BEM class naming. Twig gives you control over the markup Drupal outputs. BEM keeps the CSS around it predictable. Here's how both work on building components that work for everyone.

Key Takeaways

  • Accessibility in a Drupal theme is decided in the .html.twig markup. Modules added later can only patch around it.
  • BEM class naming (.block__element--modifier) keeps CSS predictable across Views, Layout Builder, Paragraphs, and custom blocks.
  • One accessible article card template, built with semantic HTML5, a real <a> link, and :focus-visible, works everywhere in Drupal without duplicate code.
  • Semantic markup helps search engines too. A clean heading hierarchy and real <a> links give crawlers structure to follow.
  • Test with keyboard-only navigation, a screen reader, and a 200% zoom check before calling a component done.

Why do accessibility problems start earlier than you think?

Picture this: you inherit a Drupal 7 site, upgrade it to Drupal 9, 10, or 11, and find the entire card component built with clickable divs and JavaScript event listeners. No keyboard navigation. Screen readers announce it as nothing, basically.

The client's legal team is asking about WCAG compliance.

You can't retrofit accessibility. If the base theme is built on non-semantic markup, no amount of ARIA attributes or contributed modules will truly fix it.

Take a simple content card, something teams build constantly. For a mouse user, it's just a clickable box. For a keyboard user, a screen reader user, or someone navigating by voice, that card needs:

  • Actual semantic HTML5 (<article>, <time>, proper headings, not div soup)
  • A real link (<a href="">) that keyboards can reach and activate
  • A heading hierarchy that makes sense when read linearly
  • Focus indicators you can actually see

These decisions happen in the .html.twig files. Get them wrong there, and everything built on top sits on quicksand.

What SEO benefits come from Accessible Markup?

Accessible markup helps users who need it. It also happens to improve your SEO.

  • Proper heading hierarchy tells Google what matters on your page
  • Semantic HTML5 elements like <article> and <time> give crawlers explicit context
  • Real links, not JavaScript handlers, mean bots can actually crawl your content
  • Clean, fast-loading markup improves Core Web Vitals scores

Sites often move up in search rankings once an accessibility audit fixes the underlying structure.

How does BEM fix messy Drupal Stylesheets?

Before BEM, CSS files tend to look like archaeological digs: layers of conflicting styles, each developer adding a cryptic class name of their own. Change one card style, and the header navigation breaks.

BEM gives you a naming system that just makes sense:

.card → The component itself

.card__title → A piece of the card

.card__text → Another piece

.card--featured → A variation

Instead of writing a selector like .view-content .views-row .node-teaser .field-name-title a, you write .card__link. Clear and predictable. Update every card on the site, and you edit one file.

This matters most in Drupal, where the same component might live in Views, Layout Builder, Paragraphs, or a custom block. BEM keeps it consistent everywhere without fighting specificity battles.

How do you build an accessible Card Component in Drupal?

Here's a production-ready article card, refined across a dozen or so projects.

How do you write an accessible Twig Card Template?

Drop this into templates/content/node--article--teaser.html.twig in your theme:

{#
/**
 * Article card component
 * Works in Views, Layout Builder, and wherever else you need it
 */
#}
<article{{ attributes.addClass('card') }}>
  
  {% if image %}
    <div class="card__media">
      {{ image }}
    </div>
  {% endif %}

  <div class="card__content">
    <h3 class="card__title">
      <a href="{{ url }}" class="card__link">{{ title }}</a>
    </h3>


    {% if summary %}
      <p class="card__text">{{ summary }}</p>
    {% endif %}


    <div class="card__meta">
      {% if author %}
        <span class="card__author">{{ 'By @author'|t({'@author': author}) }}</span>
      {% endif %}
      {% if date %}
        <time class="card__date" datetime="{{ date|date('c') }}">
          {{ date|format_date('custom', 'M d, Y') }}
        </time>
      {% endif %}
    </div>
  </div>


</article>

A few things worth noting. The {{ attributes }} bit is crucial: it preserves any classes that Drupal or contrib modules want to add. The |t filter handles translations, a lesson learned the hard way on a multilingual project.

That <time> element with the datetime attribute is both semantic HTML and proper microdata for search engines.

How do you structure the Sass for a BEM Card Component?

Here's the CSS/Sass that makes it work (sass/components/_card.scss):

.card {
  position: relative;
  display: flex;
  flex-direction: column;
  max-width: rem(300px);
  background: $color-white;
  border-radius: $border-radius-base;
  box-shadow: $shadow-subtle;
  transition: box-shadow 0.3s ease, transform 0.3s ease;

  // The whole card lifts on hover
  &:hover,
  &:focus-within {
    box-shadow: $shadow-elevated;
    transform: translateY(-2px);
  }

  &__media {
    aspect-ratio: 16 / 9;
    overflow: hidden;

    img {
      width: 100%;
      height: 100%;
      object-fit: cover;
      transition: transform 0.3s ease;
    }
  }

  // Subtle zoom on the image when hovering the card
  &:hover &__media img {
    transform: scale(1.05);
  }


  &__content {
    display: flex;
    flex-direction: column;
    padding: rem(24px);
  }

  &__title {
    margin: 0 0 rem(12px);
    font-size: rem(20px);
    font-weight: $font-weight-bold;
    line-height: 1.3;
  }

  &__link {
    color: inherit;
    text-decoration: none;

    // This is the magic—makes the whole card clickable
    // while keeping proper link semantics
    &::after {
      content: '';
      position: absolute;
      inset: 0;
    }

    // Hide default focus, we'll use focus-visible instead
    &:focus {
      outline: none;
    }

    // Only show focus outline for keyboard users
    &:focus-visible {
      outline: 3px solid $color-focus;
      outline-offset: 3px;
      border-radius: $border-radius-base;
    }
  }

  &__text {
    margin: 0 0 rem(16px);
    color: $color-text-secondary;
    line-height: 1.6;
  }

  &__meta {
    display: flex;
    gap: rem(12px);
    margin-top: auto;
    padding-top: rem(12px);
    border-top: 1px solid $color-border;
    font-size: rem(14px);
    color: $color-text-tertiary;
  }

  // Featured variant for hero sections
  &--featured {
    flex-direction: row;
    max-width: 100%;

    .card__media {
      width: 40%;
    }


    .card__content {
      width: 60%;
    }
  }
}

How do you attach the Library to your Drupal Theme?

In mytheme.libraries.yml:

components:
  css:
    component:
      css/components/card.css: {}
  dependencies:
    - core/drupal

Then attach it in mytheme.theme:

function mytheme_preprocess_node(&$variables) {
  if ($variables['view_mode'] == 'teaser') {
    $variables['#attached']['library'][] = 'mytheme/components';
  }
}

How do you extend this pattern to Buttons and other Components?

The same semantic-Twig-plus-BEM approach carries over to buttons, forms, and navigation. Here's a button block built the same way (sass/components/_button.scss):

.button {
  display: inline-flex;
  padding: rem(12px) rem(24px);
  
  &--primary {
    background: $color-primary;
    color: $color-white;
  }
 
  &--secondary {
    background: transparent;
    border: 2px solid $color-primary;
  }
}

Pair it with a real <button> or <a> element in the Twig template, never a styled <div>, and the same keyboard and screen reader behavior you get from the card carries straight over.

How do you make an entire Card Clickable without breaking Accessibility?

Here's something developers get wrong constantly: making entire cards clickable. The temptation is to slap an onclick handler on a div. Don't.

The ::after pseudo-element in the CSS creates an invisible overlay covering the entire card, stretching from the actual <a> tag. That single decision means:

  • Screen readers correctly announce it as a link
  • Keyboard users can tab to it naturally
  • The URL shows up in the browser's status bar
  • Right-click menus work properly
  • It's semantically correct HTML

The :focus-visible selector only shows a focus outline when someone is actually using a keyboard to navigate. Mouse users never see it. Designers stay happy, and accessibility holds up.

See the difference in code. Here's a card built as a plain, non-semantic div versus the same card built with the semantic BEM pattern above:

<div class="comparison">
  <!-- Bad Example -->
  <div class="comparison-section">
    <h2 class="section-title">
      ❌ Without Accessibility
      <span class="badge badge-bad">Non-semantic</span>
    </h2>

    <div class="bad-card" onclick="alert('Click handler on div')">
      <div class="bad-card-image">🖼️</div>
      <div class="bad-card-content">
        <div class="bad-card-title">
          Building Better Drupal Sites
        </div>
        <div class="bad-card-text">
          Learn how to create modern, responsive themes with advanced techniques and best practices.
        </div>
        <div class="bad-card-meta">
          John Doe • Feb 17, 2026
        </div>
      </div>
    </div>
  </div>

  <!-- Good Example -->
  <div class="comparison-section">
    <h2 class="section-title">
      ✅ With Accessibility
      <span class="badge badge-good">Semantic BEM</span>
    </h2>

    <article class="card">
      <div class="card__media">
        <div class="card__media-icon">🖼️</div>
      </div>
      <div class="card__content">
        <h3 class="card__title">
          <a href="#demo" class="card__link">Building Better Drupal Sites</a>
        </h3>
        <p class="card__text">
          Learn how to create modern, responsive themes with advanced techniques and best practices.
        </p>
        <div class="card__meta">
          <span class="card__author">By John Doe</span>
          <time class="card__date" datetime="2026-02-17">Feb 17, 2026</time>
        </div>
      </div>
    </article>
  </div>
</div>

Why does this same Component work everywhere in Drupal?

Build it once, and it runs everywhere. In Views, set your content to display as "Teaser," and the template applies. In Layout Builder, drop in a content block, choose "Teaser" mode, and the cards render the same way.

No duplicate code. No "why does this look different in Views?" debugging sessions at 11 PM.

How do you test a Drupal Theme for Accessibility?

Run through this manual checklist before calling a component done:

  • Can you tab to every interactive element?
  • Does the focus indicator show up clearly?
  • Fire up NVDA or VoiceOver. Does it make sense read aloud?
  • Check color contrast (aim for WCAG 2.1 AA minimum: 4.5:1)
  • Zoom to 200% in your browser. Does it still work?

What Accessibility mistakes should you avoid in Drupal Theming?

Don't build on Drupal's default classes:

// This will break when Drupal updates
.node--view-mode-teaser .field--name-title { }

// This won't
.card__title { }

Drupal's default classes look convenient because they're already in the markup. But they're implementation details, not a stable API. A module update can rename or reorder them without warning, and the styling breaks with no clear cause. A dedicated BEM class like .card__title stays put no matter what changes underneath it.

Work with Drupal's render system, not against it:

{# Wrong - skips caching and preprocessing #}
{{ node.field_title.value }}

{# Right - lets Drupal do its thing #}
{{ content.field_title }}

Reaching straight into node.field_title.value feels like a shortcut, but it skips Drupal's render pipeline, along with the caching and access checks that pipeline normally handles. Sticking to {{ content }} and its render arrays keeps that plumbing intact, so the field keeps working the way editors and other modules expect.

Keep modifiers meaningful. A card component rarely stays one size for long. Add a hero variant and a compact list variant, and modifier classes like .card--small, .card--medium, and .card--large start piling up. CSS custom properties handle that variation without a new class for every size, so the stylesheet stays readable as the component grows.

Why does Accessible Theming actually matter?

Accessibility can look like a nice-to-have feature, something added at the end if there's time. Watching a partially sighted family member struggle with poorly built websites, and working on a project that faced legal action over accessibility issues, tends to change that view fast.

Building with semantic Twig and BEM from the start pays off in a few concrete ways. Users get experiences that work regardless of how they access a site. Developers, including future ones on the project, get maintainable code that still makes sense six months later.

Site owners get better search rankings, lower legal risk, and users who stick around.

Where should you start with Accessible Drupal Theming?

Don't try to refactor an entire theme at once; that path is painful. Pick one component, a card or a button, and build it right. Test it thoroughly, get comfortable with the pattern, then expand from there.

Final thoughts

Accessibility in Drupal theming isn't a checklist tacked on after launch. It starts with the markup, semantic HTML5, real links, and clean heading structure inside every .html.twig file. Pair that with BEM naming, and the same component holds up everywhere it's used.

Start with one component. Get it right, test it properly, and let that pattern guide everything else you build from there.

If your team is ready to build, or fix, a Drupal theme that works for everyone, talk to Specbee's Drupal development team.

Frequently Asked Questions

Why can't ARIA attributes fix an inaccessible Drupal theme?

ARIA attributes patch behavior; they don't create structure. If a card is built from divs instead of a semantic <article> and a real <a> link, no amount of aria-* markup gives keyboard and screen reader users the navigation they need. The fix has to happen in the .html.twig file, not after the fact.

What is BEM, and why use it in Drupal theming?

BEM is a CSS naming convention: block, element, modifier, written as .card, .card__title, .card--featured. In Drupal, the same component often renders through Views, Layout Builder, Paragraphs, and custom blocks. BEM keeps its styling consistent across all of them without specificity battles.

How do you make a whole card clickable without breaking accessibility?

Use a real <a href=""> inside the card, then stretch an invisible ::after pseudo-element over the entire component instead of adding an onclick handler to a div. Screen readers still announce it as a link, keyboard users can tab to it, and right-click and status-bar behavior stay intact.

Does accessible markup actually help SEO?

Yes. Semantic elements like <article> and <time>, a clean heading hierarchy, and real crawlable links give search engines clearer signals about page structure and improve Core Web Vitals. Several teams have seen ranking gains after an accessibility audit fixed the underlying markup, not from any separate SEO change.

What's the minimum color contrast for WCAG 2.1 AA?

A contrast ratio of 4.5:1 between text and background is the WCAG 2.1 AA minimum for normal-size text. It's one of five checks worth running on every component: keyboard reachability, visible focus indicators, screen reader sense-check, contrast, and behavior at 200% browser zoom.

Can one Twig card template work in Views, Layout Builder, and Paragraphs at once?

Yes, when it's built against Drupal's render pipeline rather than a specific display context. A template placed at templates/content/node--article--teaser.html.twig and using {{ attributes }} and {{ content.field_x }} renders the same way whether it's called from a Views teaser, a Layout Builder block, or a custom block, with no duplicate markup to maintain.

What's the most common mistake developers make when theming Drupal components?

Writing CSS selectors against Drupal's default classes, like .node--view-mode-teaser .field--name-title, instead of a stable BEM class like .card__title. Default classes shift when Drupal core or a module updates, which quietly breaks styling. A dedicated BEM class stays stable across updates.

Should modifier classes like .card--small and .card--large be avoided?

They're not wrong, but they multiply fast once a component needs several size or spacing variants. CSS custom properties handle those variations without adding a new modifier class for every size, which keeps the BEM naming from Drupal-theming templates readable as a component grows.

Contact us

LET'S DISCUSS YOUR IDEAS. 
WE'D LOVE TO HEAR FROM YOU.

CONTACT US SUBMIT RFP