Building Dynamic Tabs In Drupal With JavaScript And CSS
Tabbed interfaces are useful when a Drupal page contains related information that should remain available without forcing visitors through a long scroll. Product specifications, documentation steps, service options, event details and editorial guidance can all fit naturally into a tabs component.
A reliable implementation needs more than a few clickable headings. The component should expose meaningful HTML, work with keyboard navigation, preserve a usable experience when JavaScript is unavailable, and integrate with Drupal’s behaviour system so it continues to work after AJAX requests.
This approach uses semantic buttons, ARIA state, a Drupal library, and a small amount of vanilla JavaScript. It is suitable for Drupal 10 and Drupal 11 themes, with styling that can be adapted to a custom design system or a responsive Australian business website serving visitors from Sydney, Melbourne, Brisbane and regional areas.
Choosing Markup That Works Without JavaScript
The tab list should contain real buttons rather than links pretending to be controls. Each button receives an id, an aria-controls value and an aria-selected state. Every panel receives a matching aria-labelledby value and a role="tabpanel" attribute.
A useful Twig structure might look like this:
<div class="tabs" data-tabs>
<div class="tabs__list" role="tablist" aria-label="Product information">
{% for tab in tabs %}
<button
class="tabs__button{% if loop.first %} is-active{% endif %}"
id="tab-{{ tab.id }}"
type="button"
role="tab"
aria-controls="panel-{{ tab.id }}"
aria-selected="{{ loop.first ? 'true' : 'false' }}"
tabindex="{{ loop.first ? '0' : '-1' }}">
{{ tab.label }}
</button>
{% endfor %}
</div>
{% for tab in tabs %}
<section
class="tabs__panel{% if not loop.first %} is-hidden{% endif %}"
id="panel-{{ tab.id }}"
role="tabpanel"
aria-labelledby="tab-{{ tab.id }}"
tabindex="0">
{{ tab.content }}
</section>
{% endfor %}
</div>
The is-hidden class should be paired with the native hidden attribute or equivalent CSS behaviour. Keeping inactive content in the document makes the component easier to enhance and avoids forcing all content through a client-side template. If the tabs represent important content, consider rendering the first panel visibly by default so the page remains useful during a slow load.
Registering The Drupal Library
Drupal assets belong in a library rather than being added directly from a theme template. In a custom theme, create a file such as mytheme.libraries.yml:
tabs:
version: 1.x
css:
component:
css/components/tabs.css: {}
js:
js/tabs.js: {}
dependencies:
- core/drupal
- core/once
Attach the library from Twig with:
{{ attach_library('mytheme/tabs') }}
It can also be attached in a preprocess function, a render array, or a custom block plugin. A render array is often preferable when the tabs are generated from configuration, paragraph fields or a reusable component because the asset dependency travels with the component.
For a content editor workflow, create a structured field containing a tab title and formatted body. A custom paragraph type can expose these fields without requiring editors to write HTML. Drupal’s cache metadata should still be preserved when the content is assembled dynamically, especially if access permissions or language negotiation affect the panel output.
Adding JavaScript With Drupal Behaviours
Drupal behaviours are important because content can be inserted after the first page load through Views AJAX, BigPipe, modal dialogs or administrative interfaces. The once() utility prevents the same tab group from being initialised repeatedly.
(function (Drupal, once) {
Drupal.behaviors.dynamicTabs = {
attach(context) {
once('dynamic-tabs', '[data-tabs]', context).forEach((component) => {
const buttons = Array.from(
component.querySelectorAll('[role="tab"]')
);
const panels = Array.from(
component.querySelectorAll('[role="tabpanel"]')
);
const activate = (button, moveFocus = false) => {
buttons.forEach((item) => {
const selected = item === button;
item.setAttribute('aria-selected', selected);
item.tabIndex = selected ? 0 : -1;
});
panels.forEach((panel) => {
const visible = panel.id === button.getAttribute('aria-controls');
panel.hidden = !visible;
});
if (moveFocus) {
button.focus();
}
};
buttons.forEach((button, index) => {
button.addEventListener('click', () => activate(button));
button.addEventListener('keydown', (event) => {
let nextIndex = index;
if (event.key === 'ArrowRight') {
nextIndex = (index + 1) % buttons.length;
} else if (event.key === 'ArrowLeft') {
nextIndex = (index - 1 + buttons.length) % buttons.length;
} else if (event.key === 'Home') {
nextIndex = 0;
} else if (event.key === 'End') {
nextIndex = buttons.length - 1;
} else {
return;
}
event.preventDefault();
activate(buttons[nextIndex], true);
});
});
});
}
};
})(Drupal, once);
The component now supports mouse, touch, keyboard arrows, Home and End. The selected button retains focus after activation, which helps keyboard users understand where they are. This is especially important on content-heavy sites where a panel may include several headings, forms or embedded media.
If tabs are generated inside an AJAX response, Drupal.attachBehaviors() will process the new markup. Avoid using a global selector such as document.querySelectorAll() without the supplied context, since that can initialise old components again and create duplicate event listeners.
Styling The Interface For Responsive Layouts
CSS should make the active state obvious without relying on colour alone. A bottom border, weight change or visible background can reinforce the selected tab, while sufficient spacing keeps controls practical on touchscreens.
.tabs__list {
display: flex;
gap: 0.25rem;
overflow-x: auto;
border-bottom: 1px solid #c8cdd2;
scrollbar-width: thin;
}
.tabs__button {
flex: 0 0 auto;
padding: 0.8rem 1rem;
border: 0;
border-bottom: 3px solid transparent;
background: transparent;
color: #27323a;
cursor: pointer;
font: inherit;
}
.tabs__button:hover,
.tabs__button:focus-visible {
background: #eef4f7;
}
.tabs__button[aria-selected="true"] {
border-bottom-color: #006b8f;
color: #004f6b;
font-weight: 700;
}
.tabs__panel {
padding: 1.25rem 0;
}
.tabs__panel[hidden] {
display: none;
}
@media (max-width: 640px) {
.tabs__button {
min-height: 3rem;
padding-inline: 0.85rem;
}
}
Horizontal scrolling is generally better than wrapping a large number of tabs into several rows. On a phone used during a commute in Melbourne or while checking a service page in regional New South Wales, a predictable single row is easier to understand. Keep labels short, and avoid hiding the active tab outside the visible scroll area.
Focus styles should remain visible in every theme. If the site uses a dark header or a branded colour palette, test the contrast of the active border and focus outline against both the page background and the tab panel. Australian government and education websites often receive traffic from a wide range of devices and assistive technologies, so accessibility should be treated as a core rendering requirement rather than a later refinement.
Managing State And Content In Drupal
The following comparison helps decide how much behaviour belongs in JavaScript and how much should remain in Drupal-rendered markup.
| Requirement | Drupal or HTML responsibility | JavaScript responsibility |
|---|---|---|
| Tab labels and panel content | Render valid buttons and sections | Read existing relationships |
| Initial active panel | Mark the first tab selected and show its panel | Confirm state during initialisation |
| Keyboard navigation | Provide focusable buttons | Handle arrows, Home and End |
| AJAX-loaded tabs | Return complete component markup | Initialise through behaviours |
| Deep linking | Add stable IDs and optional URL data | Read and update the URL |
| No-script experience | Keep useful content in the document | Enhance interaction only |
| Permissions and caching | Build content with Drupal access checks | Never decide user permissions |
For reusable components, stable IDs matter. IDs based on a database entity or machine name are preferable to random values because they can support fragment links and automated tests. Sanitise labels before turning them into IDs, and make sure two separate tab groups on the same page cannot produce duplicate identifiers.
A tab component should not be used to conceal content that visitors must compare frequently. Pricing, legal terms and critical instructions may be clearer as sections on the page. Tabs work best where content is related but independent, such as “Overview”, “Specifications” and “Support”. For an automotive documentation example, a panel could link to a FD3S VIN guide when the surrounding content discusses chassis identification and production records.
Supporting URL Fragments And Dynamic Loading
Adding optional URL support allows a visitor to share a link that opens a particular panel. Give each tab a stable fragment, such as #shipping, and activate it after the component is attached.
const activateFromHash = () => {
const hash = window.location.hash.slice(1);
const matchingButton = buttons.find(
(button) => button.getAttribute('aria-controls') === hash
);
if (matchingButton) {
activate(matchingButton);
}
};
activateFromHash();
window.addEventListener('hashchange', activateFromHash);
If the tab click should update the address, call history.replaceState() rather than forcing a page reload. This makes copied links useful while preserving the visitor’s scroll position. Avoid changing the URL for every interaction if the component appears in a small card or filter control where fragment navigation would feel surprising.
For large panels, Drupal can load content on demand through an endpoint or an AJAX command. Use this only when the content is genuinely expensive to render. A loading message, error state and retry path are essential. Visitors on a slower NBN connection, a mobile hotspot near Adelaide, or a corporate network in Perth should still receive clear feedback rather than an empty panel.
When loading remote content, keep permission checks on the server. JavaScript may request a panel, but Drupal must decide whether the current user can view it. Cache responses appropriately and escape or sanitise any content that is inserted into the DOM.
Testing Accessibility, Caching And Browser Behaviour
Test the component with a keyboard before testing visual polish. Tab to the first control, move with the arrow keys, activate a panel with Enter or Space, and confirm that focus does not disappear. Screen reader output should announce the selected state and the relationship between each button and its panel.
Use browser developer tools to disable JavaScript and confirm that the page still presents meaningful content. A no-script fallback may show all panels in sequence, or the Twig template can render the first panel while keeping headings available. The right choice depends on whether the component contains navigation, product data, forms or editorial content.
Check Drupal’s cache contexts when tab content varies by language, user role or URL. Clear caches after changing library definitions, Twig templates or asset files. Test both anonymous and authenticated users, because an administrator preview can hide access and caching problems that ordinary visitors will encounter.
Responsive testing should include narrow Android and iPhone screens, desktop browsers, zoom at 200 percent, and touch interaction. In Australia, a site may serve customers across several time zones and a mix of metropolitan and regional networks, so a small, dependency-free component is often more dependable than a large interface library.
Extending The Component Safely
Once the base tabs work, the component can support vertical layouts, icons, badges, animated transitions or an accordion mode for small screens. Add these features without changing the underlying button-panel relationship. The accessible name and selected state should remain available to assistive technology regardless of visual presentation.
An animation should respect the user’s reduced-motion preference:
@media (prefers-reduced-motion: no-preference) {
.tabs__panel {
animation: tabs-fade 160ms ease-out;
}
}
@keyframes tabs-fade {
from {
opacity: 0;
transform: translateY(0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Keep the JavaScript module small and document the expected markup near the component files. Drupal site builders may reuse the pattern in Layout Builder, a custom block or a paragraph library months after it was first created. Clear class names, predictable data attributes and a modest API reduce maintenance work for both developers and editors.
A dynamic tabs component is successful when it feels ordinary: the right panel appears quickly, the control remains understandable on a phone, keyboard users can move confidently, and Drupal continues to render useful content under changing cache and AJAX conditions. Semantic markup provides the foundation, while JavaScript and CSS add the interaction without taking ownership of the page.