The collapsible component provides a clean solution for managing large, secondary content or detailed information that might otherwise clutter the interface. When implemented correctly, it allows users to progressively discover content based on their interest level, maintaining a clean, focused interface by default.
This pattern is particularly useful for:
- FAQ sections
- Detailed specifications
- Supplementary information
- Long descriptions that would disrupt the main content flow
Native HTML Solution
Basic HTML5 provides built-in <details> and <summary> elements for collapsible content that require no JavaScript and work across all modern browsers:
Native HTML Example
HTML5 provides built-in elements for collapsible content that require no JavaScript and work across all modern browsers:
<details>
<summary>Click to expand</summary>
<p>This content is hidden by default and appears when the user clicks the summary.</p>
</details>
Using pure HTML elements offer several advantages:
- Zero JavaScript dependency
- Built-in accessibility features
- Native browser support
- Consistent behavior across platforms
JavaScript
For cases requiring more complex behavior or specific design patterns that go beyond what native HTML elements provide, a custom JavaScript implementation is available. Here it is, live:
This block is a live collapsible driven by the script from this page. The trigger carries the hook class rd-js-collapsible, this content block carries rd-js-collapsible-content, and the open state is the rd-is-open class plus aria-expanded on the trigger. The icon rotation is pure CSS — no glyph swapping.
Ready-to-paste markup for all three supported structures is right below.
Ready-to-paste markup
The script resolves the content element in three ways, in order: explicit aria-controls pointing at the content’s id (canonical), the trigger’s next sibling, or the trigger’s parent’s next sibling.
Canonical — explicit aria-controls:
<button class="collapsible-trigger rd-js-collapsible" aria-controls="details-1">
Trigger<span class="collapsible-icon"></span>
</button>
<div id="details-1" class="collapsible-content rd-js-collapsible-content">
Content goes here
</div>
Structure 1 — trigger and content are siblings:
<div class="collapsible-trigger rd-js-collapsible">Trigger</div>
<div class="collapsible-content rd-js-collapsible-content">Content goes here</div>
Structure 2 — trigger inside a parent (like a <p>), content right after that parent:
<p>
<span class="collapsible-trigger rd-js-collapsible">Trigger</span>
</p>
<div class="collapsible-content rd-js-collapsible-content">Content goes here</div>
Script include and minimum CSS (Rare Styles already ships these styles — add them only when using the script standalone):
<script src="/assets/js/collapsible.js"></script>
<style>
.collapsible-content { display: none; }
.collapsible-content.rd-is-open { display: block; }
</style>
Note: The .collapsible-icon element is optional, and it is empty on purpose — the arrow glyph is baked into the class by CSS (Material Symbols ligature via : before), so markup carries no vendor icon class and no ligature text. The script never swaps icon glyphs — the open state rotates the icon via CSS ([aria-expanded="true"].collapsible-icon). Standalone users need:
.collapsible-icon {
display: inline-block;
font-family: "Material Symbols Outlined";
transition: transform 0.2s ease;
}
.collapsible-icon::before { content: "keyboard_arrow_down"; }
.collapsible-trigger[aria-expanded="true"] .collapsible-icon {
transform: rotate(180deg);
}
⚠️ Performance tip: For production, download the script and host it on your own server rather than loading it from a CDN.
Technical Requirements
Pay attention for several points:
- The clickable element must carry the hook class
rd-js-collapsible(keep a separate presentational class like.collapsible-triggerfor styling — hooks are never styled) - The content element must carry the hook class
rd-js-collapsible-content, or be targeted explicitly viaaria-controls+id - Optional: icon element with class
.collapsible-icon— rotated by CSS when open, no glyph swapping - The content must be hidden by default via CSS (
display: none;) and shown by the state class the script toggles:.rd-is-open { display: block; } - The script mirrors open/closed state to
aria-expandedon the trigger. A<button>trigger is preferred; for any other element the script addstabindex="0"+role="button"and handles Enter / Space, so keyboard access works either way
Raw code
document.addEventListener('DOMContentLoaded', function() {
// Resolve the content element for a trigger:
// 1. explicit aria-controls id, 2. trigger's next sibling, 3. trigger's parent's next sibling
function resolveContent(trigger) {
const id = trigger.getAttribute('aria-controls');
if (id) {
const byId = document.getElementById(id);
if (byId) return byId;
}
const sibling = trigger.nextElementSibling;
if (sibling && sibling.classList.contains('rd-js-collapsible-content')) {
return sibling;
}
const parentSibling = trigger.parentElement && trigger.parentElement.nextElementSibling;
if (parentSibling && parentSibling.classList.contains('rd-js-collapsible-content')) {
return parentSibling;
}
return null;
}
document.querySelectorAll('.rd-js-collapsible').forEach(trigger => {
const content = resolveContent(trigger);
if (!content) return;
// Initialize ARIA from the authored state
const isOpen = content.classList.contains('rd-is-open');
trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
if (content.id && !trigger.getAttribute('aria-controls')) {
trigger.setAttribute('aria-controls', content.id);
}
function toggle() {
const open = content.classList.toggle('rd-is-open');
trigger.classList.toggle('rd-is-open', open);
trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
}
trigger.addEventListener('click', toggle);
// Keyboard access when the trigger is not a native button:
// make it focusable and respond to Enter / Space
if (trigger.tagName !== 'BUTTON') {
if (!trigger.hasAttribute('tabindex')) trigger.setAttribute('tabindex', '0');
if (!trigger.hasAttribute('role')) trigger.setAttribute('role', 'button');
trigger.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
});
}
});
});
Changelog
v2.0.0
- Breaking: hooks moved to the
rd-jscontract — the script finds elements viard-js-collapsible/rd-js-collapsible-content;.collapsible-trigger/.collapsible-contentremain as presentational classes only - Breaking: visibility is now expressed by the
rd-is-openstate class instead of inlinestyle.display - Breaking: the icon glyph is no longer swapped (
keyboard_arrow_down↔keyboard_arrow_up); the icon rotates via CSS keyed offaria-expanded - Added
aria-expanded/aria-controls; explicitaria-controlstargeting is now the canonical structure - Added keyboard access: non-
<button>triggers gettabindex="0"+role="button"and toggle on Enter / Space
Pseudo-Link Styling Cheat Sheet
Create a special style for pseudo-links.
Following best practices, .collapsible-trigger elements should be visually distinguished to indicate their interactive nature.
When designing the visual treatment for collapsible triggers, aim for a balance between subtlety and clarity. Users should recognize them as interactive elements without disrupting the flow of content.
Bonus: If it’s part of a bigger text block, avoid display: block — it should feel natural inside the flow.