Collapsible Content

js/collapsible.js

v2.0.0 Stable (breaking: `rd-js-` hook) | Download download

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:

code

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:

  1. The clickable element must carry the hook class rd-js-collapsible (keep a separate presentational class like .collapsible-trigger for styling — hooks are never styled)
  2. The content element must carry the hook class rd-js-collapsible-content, or be targeted explicitly via aria-controls + id
  3. Optional: icon element with class .collapsible-icon — rotated by CSS when open, no glyph swapping
  4. 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; }
  5. The script mirrors open/closed state to aria-expanded on the trigger. A <button> trigger is preferred; for any other element the script adds tabindex="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-js contract — the script finds elements via rd-js-collapsible / rd-js-collapsible-content; .collapsible-trigger / .collapsible-content remain as presentational classes only
  • Breaking: visibility is now expressed by the rd-is-open state class instead of inline style.display
  • Breaking: the icon glyph is no longer swapped (keyboard_arrow_downkeyboard_arrow_up); the icon rotates via CSS keyed off aria-expanded
  • Added aria-expanded / aria-controls; explicit aria-controls targeting is now the canonical structure
  • Added keyboard access: non-<button> triggers get tabindex="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.