<div id="cwf-accordion--example-accordion--single" class="cwf-accordion">
    <div class="cwf-accordion__wrapper">
        <div id="cwf-accordion__panel--example-accordion--single--1" class="cwf-accordion__panel" role="dialog" aria-labelledby="cwf-accordion__title--example-accordion--single--1" aria-describedby="cwf-accordion__body--example-accordion--single--1">
            <div class="cwf-accordion__heading" tabindex="0" aria-controls="cwf-accordion__overflow--example-accordion--single--1" aria-expanded="true">
                <svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="chevron-up" class="svg-inline--fa fa-chevron-up fa-w-14 cwf-accordion__chevron" role="presentation" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
                    <path fill="currentColor" d="M240.971 130.524l194.343 194.343c9.373 9.373 9.373 24.569 0 33.941l-22.667 22.667c-9.357 9.357-24.522 9.375-33.901.04L224 227.495 69.255 381.516c-9.379 9.335-24.544 9.317-33.901-.04l-22.667-22.667c-9.373-9.373-9.373-24.569 0-33.941L207.03 130.525c9.372-9.373 24.568-9.373 33.941-.001z" />
                </svg>
                <strong id="cwf-accordion__title--example-accordion--single--1" class="cwf-accordion__title">
                    Panel title
                </strong>
            </div>
            <div id="cwf-accordion__overflow--example-accordion--single--1" class="cwf-accordion__overflow">
                <div id="cwf-accordion__body--example-accordion--single--1" class="cwf-accordion__body">
                    <p>
                        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed imperdiet
                        risus suscipit sapien congue pretium. In hac habitasse platea dictumst.
                        Donec vel nisi quis dui aliquam porta. Sed nec dolor ullamcorper velit
                        suscipit elementum. Suspendisse et augue vitae tellus congue mollis.
                        Integer sem ex, rhoncus eget cursus non, rhoncus eget ipsum. Praesent
                        ullamcorper facilisis diam, sit amet commodo sem auctor egestas. <a href="https://www.vcu.edu/">
                            Virginia Commonwealth University
                        </a> Vivamus vestibulum, ligula vitae molestie finibus, est lacus ornare
                        nisl, quis ultrices massa mi vitae augue. Pellentesque nulla lectus, placerat
                        id tellus ac, fringilla aliquam velit. Aliquam ullamcorper consectetur urna,
                        vitae maximus nunc malesuada eget. Vestibulum lobortis ut quam a congue.
                        Phasellus facilisis erat at feugiat faucibus. Curabitur cursus dolor in laoreet
                        sodales.
                    </p>
                </div>
            </div>
        </div>
    </div>
</div>
{% set id = id ?? panels[0].id %}
{% set accordionSuffix = id ? '--' ~ id : '' %}
{% set accordionId = 'cwf-accordion' ~ accordionSuffix %}
<div id="{{ accordionId }}" class="cwf-accordion">
    {%- if panels.length > 1 -%}
        <button class="cwf-accordion__toggle"
            aria-controls="{{ accordionId }}"
            data-action="true">
            Expand All
        </button>
    {%- endif -%}
    <div class="cwf-accordion__wrapper">
        {%- for panel in panels -%}
            {% set panelIndex = panel.id ?? loop.index %}
            {% set panelSuffix = id is defined
                ? id ~ '--' ~ panelIndex
                : panelIndex
            %}
            {% set panelId = 'cwf-accordion__panel--' ~ panelSuffix %}
            {% set panelTitleId = 'cwf-accordion__title--' ~ panelSuffix %}
            {% set panelBodyId = 'cwf-accordion__body--' ~ panelSuffix %}
            {% set panelOverflowId = 'cwf-accordion__overflow--' ~ panelSuffix
            %}
            <div id="{{ panelId }}"
                class="cwf-accordion__panel"
                role="dialog"
                aria-labelledby="{{ panelTitleId }}"
                aria-describedby="{{ panelBodyId }}">
                <div class="cwf-accordion__heading"
                    tabindex="0"
                    aria-controls="{{ panelOverflowId }}"
                    aria-expanded="{{ panel.open ?? false }}">
                    {% include '../../shared/icons/chevron-up-solid.svg' with {
                        class: 'cwf-accordion__chevron',
                        role: 'presentation'
                    } %}
                    <strong id="{{ panelTitleId }}"
                        class="cwf-accordion__title">
                        {{ panel.title }}
                    </strong>
                </div>
                <div id="{{ panelOverflowId }}" class="cwf-accordion__overflow">
                    <div id="{{ panelBodyId }}" class="cwf-accordion__body">
                        {{ panel.body }}
                    </div>
                </div>
            </div>
        {%- endfor -%}
    </div>
</div>
{
  "id": "example-accordion--single",
  "panels": [
    {
      "open": true,
      "title": "Panel title",
      "body": "<p>\n    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed imperdiet\n    risus suscipit sapien congue pretium. In hac habitasse platea dictumst.\n    Donec vel nisi quis dui aliquam porta. Sed nec dolor ullamcorper velit\n    suscipit elementum. Suspendisse et augue vitae tellus congue mollis.\n    Integer sem ex, rhoncus eget cursus non, rhoncus eget ipsum. Praesent\n    ullamcorper facilisis diam, sit amet commodo sem auctor egestas. <a href=\"https://www.vcu.edu/\">\n        Virginia Commonwealth University\n    </a> Vivamus vestibulum, ligula vitae molestie finibus, est lacus ornare\n    nisl, quis ultrices massa mi vitae augue. Pellentesque nulla lectus, placerat\n    id tellus ac, fringilla aliquam velit. Aliquam ullamcorper consectetur urna,\n    vitae maximus nunc malesuada eget. Vestibulum lobortis ut quam a congue.\n    Phasellus facilisis erat at feugiat faucibus. Curabitur cursus dolor in laoreet\n    sodales.\n</p>"
    }
  ]
}
  • Content:
    // Accordion component styles
    
    @import "../../shared/animation";
    @import "../../shared/media";
    @import "../../shared/style";
    @import "../../shared/theme";
    
    // Accordion colors
    $accordion--foreground-light-color: style__color(gray-light) !default;
    $accordion--foreground-medium-color: style__color(gray-dark) !default;
    $accordion--foreground-dark-color: style__color(black) !default;
    $accordion--background-light-color: style__color(white-dark) !default;
    $accordion--background-medium-color: darken(
        style__color(white-dark),
        5%
    ) !default;
    $accordion--background-dark-color: style__color(white-darkest) !default;
    
    .cwf-accordion {
        display: flex;
        flex-direction: column;
        align-items: flex-end;
        @include style__spacing;
    
        --cwf-accordion--foreground-light-color: #{$accordion--foreground-light-color};
        --cwf-accordion--foreground-medium-color: #{$accordion--foreground-medium-color};
        --cwf-accordion--foreground-dark-color: #{$accordion--foreground-dark-color};
        --cwf-accordion--background-light-color: #{$accordion--background-light-color};
        --cwf-accordion--background-medium-color: #{$accordion--background-medium-color};
        --cwf-accordion--background-dark-color: #{$accordion--background-dark-color};
    }
    
    .cwf-accordion__toggle {
        margin-bottom: 0.5rem;
        padding: 0;
        border: none;
        background-color: transparent;
        font-size: 1rem;
        text-decoration: underline;
        font-family: theme__font--sans-serif();
        color: var(--cwf-accordion--foreground-light-color);
        @include animation__transition(color);
    
        @include style__cursor;
    
        &:hover,
        &:focus {
            color: var(--cwf-accordion--foreground-medium-color);
        }
    }
    
    .cwf-accordion__wrapper {
        width: 100%;
        margin-bottom: -1px;
        overflow: hidden;
        @include style__shadow;
    }
    
    .cwf-accordion__panel {
        border-top: 1px solid var(--cwf-accordion--background-medium-color);
        &:first-child {
            border-top: none;
        }
        &:last-child {
            margin-bottom: 0;
        }
    }
    
    .cwf-accordion__heading {
        display: flex;
        align-items: center;
        margin-bottom: -1px;
        padding: 0.75rem 1.5rem;
        border-bottom: 1px solid var(--cwf-accordion--background-dark-color);
        background-color: var(--cwf-accordion--background-light-color);
        font-family: theme__font--sans-serif();
        color: var(--cwf-accordion--foreground-medium-color);
        @include animation__transition(background-color, color);
    
        @include style__cursor;
    
        &:hover,
        &:focus {
            background-color: var(--cwf-accordion--background-medium-color);
            color: var(--cwf-accordion--foreground-dark-color);
        }
    }
    
    .cwf-accordion__heading[aria-expanded="false"] {
        color: var(--cwf-accordion--foreground-light-color);
    
        &:hover,
        &:focus {
            color: var(--cwf-accordion--foreground-medium-color);
        }
    }
    
    .cwf-accordion__chevron {
        min-width: 1rem;
        width: 1rem;
        margin-right: 1.5rem;
        @include animation__transition(color, transform);
    }
    
    .cwf-accordion__heading[aria-expanded="false"] .cwf-accordion__chevron,
    .cwf-accordion__heading--closing .cwf-accordion__chevron {
        @include animation__flip;
    }
    
    .cwf-accordion__title {
        font-size: 1rem;
        @include animation__transition(color);
    }
    
    .cwf-accordion__overflow {
        display: block;
        overflow: hidden;
        @include animation__transition(height);
    }
    
    .cwf-accordion__heading[aria-expanded="false"] ~ .cwf-accordion__overflow {
        display: none;
    }
    
    .cwf-accordion__body {
        padding: 1.5rem;
    
        @include style__children;
    }
    
  • URL: /components/raw/accordion/_index.scss
  • Filesystem Path: components/accordion/_index.scss
  • Size: 3.5 KB
  • Content:
    // The default component class
    import { Component } from '../../shared/component';
    
    // Check whether reduced motion is enabled locally or globally
    import { reducedMotion } from '../../shared/media';
    
    // Provide functionality to all accordions
    export class Accordion extends Component {
        constructor({
            accordion = 'cwf-accordion',
            toggle = 'cwf-accordion__toggle',
            panel = 'cwf-accordion__panel',
            heading = 'cwf-accordion__heading',
            closing = 'cwf-accordion__heading--closing',
            overflow = 'cwf-accordion__overflow',
            body = 'cwf-accordion__body'
        } = {}) {
            super();
    
            // Store all selectors
            this.selectors = {
                accordion,
                toggle,
                panel,
                heading,
                closing,
                overflow,
                body
            };
    
            // Initialize arrays for the toggles, accordions, and panels...
            this.toggles = [];
            this.accordions = [];
            this.panels = [];
            // ... as well as an object for the current event
            this.current = {};
    
            // Bind "this" to the necessary methods
            this.onClick = this.onClick.bind(this);
            this.onKeyDown = this.onKeyDown.bind(this);
            this.onHashChange = this.onHashChange.bind(this);
        }
    
        // Get references to all accordion elements
        getReferences() {
            // Attempt to grab all toggles,
            this.toggles = Array.from(
                document.querySelectorAll(`.${this.selectors.toggle}`)
            );
            // ... accordions,...
            this.accordions = Array.from(
                document.querySelectorAll(`.${this.selectors.accordion}`)
            );
            // .. and accordion panels from the page
            this.panels = [];
            this.accordions.forEach((accordion) => {
                const panels = Array.from(
                    accordion.querySelectorAll(`.${this.selectors.panel}`)
                );
                panels.forEach((panel) => {
                    const heading = panel.querySelector(
                            `.${this.selectors.heading}`
                        ),
                        overflow = panel.querySelector(
                            `.${this.selectors.overflow}`
                        ),
                        body = panel.querySelector(`.${this.selectors.body}`);
                    this.panels.push({
                        accordion,
                        panel,
                        heading,
                        overflow,
                        body
                    });
                });
            });
        }
    
        // Calculate an element's height (including padding, margin, and border)
        calcElHeight(element) {
            // Get the computed styles and top/bottom margins of the element...
            const styles = window.getComputedStyle(element),
                margins =
                    parseFloat(styles.marginTop) + parseFloat(styles.marginBottom);
            // ... and return their sum rounded up in pixels
            return Math.ceil(element.offsetHeight + margins) + 'px';
        }
    
        // Open an accordion panel
        openPanel({ heading, overflow, body }) {
            // Focus the heading of the panel if instructed to do so
            if (this.current.focus) heading.focus();
    
            // If the panel is already opened, do nothing else
            if (heading.getAttribute('aria-expanded') === 'true') return;
    
            // If the current type is "hash", or if reduced motion is enabled locally or globally,...
            if (this.current.type === 'hash' || reducedMotion())
                // ... simply open the panel with no animation
                return heading.setAttribute('aria-expanded', true);
    
            // Set the overflow's height to zero
            overflow.style.height = 0;
    
            // End the height transition
            function transitionEnd({ propertyName }) {
                // If the transition property isn't height, do nothing
                if (propertyName !== 'height') return;
    
                // Unbind from the overflow's transitionend event...
                overflow.removeEventListener('transitionend', transitionEnd);
                // ... and remove its height property
                overflow.style.removeProperty('height');
            }
    
            // Start the height transition
            function transitionStart() {
                // Bind to the overflow's transitionend event...
                overflow.addEventListener('transitionend', transitionEnd);
                // ... and set its height to that of the body's
                window.requestAnimationFrame(
                    () => (overflow.style.height = this.calcElHeight(body))
                );
            }
    
            // Set the heading's aria-expanded attribute to true...
            heading.setAttribute('aria-expanded', true);
            // ... and start the height transition
            window.requestAnimationFrame(transitionStart.bind(this));
        }
    
        // Close an accordion panel
        closePanel({ heading, overflow, body }) {
            // If the panel is already closed, do nothing else
            if (heading.getAttribute('aria-expanded') === 'false') return;
    
            // If the current type is "hash", or if reduced motion is enabled locally or globally,...
            if (this.current.type === 'hash' || reducedMotion())
                // ... simply close the panel with no animation
                return heading.setAttribute('aria-expanded', false);
    
            // Grab the closing class from the global selectors
            const { closing } = this.selectors;
    
            // Set the overflow's height to that of the body's
            overflow.style.height = this.calcElHeight(body);
    
            // End the height transition
            function transitionEnd({ propertyName }) {
                // If the transition property isn't height, do nothing
                if (propertyName !== 'height') return;
    
                // Unbind from the overflow's transitionend event,...
                overflow.removeEventListener('transitionend', transitionEnd);
                // ... set the heading's aria-expanded to false,...
                heading.setAttribute('aria-expanded', false);
                // ... remove the closing class from the heading,...
                heading.classList.remove(closing);
                // ... and remove the overflow's height property
                overflow.style.removeProperty('height');
            }
    
            // Start the height transition
            function transitionStart() {
                // Add a class to the heading signifying the panel is closing,...
                heading.classList.add(closing);
                // ... bind to the overflow's transitionend event,...
                overflow.addEventListener('transitionend', transitionEnd);
                // ... and set its height to zero
                window.requestAnimationFrame(() => (overflow.style.height = 0));
            }
    
            // Start the height transition
            window.requestAnimationFrame(transitionStart.bind(this));
        }
    
        // Set the the toggle button's action (expand or collapse)
        setToggle(action) {
            // Attempt to get the toggle to update, either from the current state or accordion...
            const toggle =
                this.current.toggle ||
                this.toggles.find(
                    (toggle) =>
                        toggle.getAttribute('aria-controls') ===
                        this.current.accordion.id
                );
            // ... and if it wasn't found, do nothing else
            if (!toggle) return;
    
            // Finally, set the toggle's text...
            toggle.textContent = action ? 'Expand All' : 'Collapse All';
            // ... and action data attribute
            toggle.setAttribute('data-action', action);
        }
    
        // Toggle the panels
        togglePanels() {
            // For each panel of the current accordion,...
            this.current.panels.forEach(({ panel, heading, overflow, body }) => {
                // Ensure the heading does not have a closing class...
                heading.classList.remove(this.selectors.closing);
                // ... and the overflow does not have a height set,...
                overflow.style.removeProperty('height');
                // ... and store all relevant panel elements
                const target = { heading, overflow, body };
    
                // If the type of action was not from a toggle and the panel does not equal the current panel, close it
                if (this.current.type !== 'toggle' && panel !== this.current.panel)
                    return this.closePanel(target);
    
                // If the current action is to open, open the panel
                if (this.current.action) return this.openPanel(target);
    
                // Otherwise, close the panel
                return this.closePanel(target);
            });
    
            // Set the toggle (flip if the action was from a toggle, otherwise always open)
            this.setToggle(
                this.current.type === 'toggle' ? !this.current.action : true
            );
    
            // Finally, reset the current accordion
            this.current = {};
        }
    
        // Find an element by type
        findElementByType(type, element, target) {
            // Define ways of finding an element based on type...
            const instructions = {
                toggle: {
                    accordion: document.getElementById(
                        target.getAttribute('aria-controls')
                    )
                },
                heading: {
                    accordion: target.parentNode.parentNode.parentNode,
                    panel: target.parentNode
                },
                hash: {
                    accordion: target.parentNode.parentNode,
                    panel: target
                }
            };
            // ... and return the proper element
            return instructions[type][element];
        }
    
        // Set action by type and target
        setActionByTypeAndTarget(type, target) {
            // If the type is "toggle", return the target's action dataset attribute...
            if (type === 'toggle') return target.dataset.action === 'true';
            // ... otherwise, return the opposite of the target's aria-expanded attribute
            return target.getAttribute('aria-expanded') !== 'true';
        }
    
        // Set the current accordion
        setCurrentAccordion(type, target, focus = false) {
            // Grab all necessary references and data...
            const accordion = this.findElementByType(type, 'accordion', target),
                panel =
                    type !== 'toggle'
                        ? this.findElementByType(type, 'panel', target)
                        : undefined,
                toggle = type === 'toggle' ? target : undefined,
                panels = this.panels.filter(
                    (panel) => panel.accordion === accordion
                ),
                action =
                    type !== 'hash'
                        ? this.setActionByTypeAndTarget(type, target)
                        : true;
            // ... and set the current accordion
            this.current = {
                type,
                accordion,
                panel,
                panels,
                toggle,
                action,
                focus
            };
        }
    
        // Handle click events
        onClick(event) {
            // Grab the current target of the event
            const { currentTarget } = event;
    
            // If the current target is a toggle, find its associated accordion accordingly...
            if (currentTarget.classList.contains(this.selectors.toggle))
                this.setCurrentAccordion('toggle', currentTarget);
            // ... and if the current target is a panel heading, find its associated accordion accordingly
            if (currentTarget.classList.contains(this.selectors.heading))
                this.setCurrentAccordion('heading', currentTarget);
    
            // Finally, prevent the default behavior...
            event.preventDefault();
            // ... and toggle the panels
            return this.togglePanels(event);
        }
    
        // Focus a panel heading
        focusHeading({ key, currentTarget }) {
            // Grab the first/last panel as well as the max index
            const firstPanel = this.current.panels[0],
                maxIndex = this.current.panels.length - 1,
                lastPanel = this.current.panels[maxIndex];
    
            // If the "Home" key was pressed, focus the first panel heading...
            if (key === 'Home') return firstPanel.heading.focus();
            // ... and if the "End" key was pressed, focus the last panel heading
            if (key === 'End') return lastPanel.heading.focus();
    
            // Get the currently focused panel and its index
            const currentPanel = this.current.panels.find(
                    (panel) => panel.heading === currentTarget
                ),
                currentIndex = this.current.panels.indexOf(currentPanel);
    
            // If the up arrow key was pressed,...
            if (key === 'ArrowUp') {
                // ... focus the last panel heading if the first panel heading is focused,...
                if (!currentIndex) return lastPanel.heading.focus();
                // ... otherwise focus the previous panel's heading
                return this.current.panels[currentIndex - 1].heading.focus();
            }
    
            // If the down arrow key was pressed,...
            if (key === 'ArrowDown') {
                // ... focus the first panel heading if the last panel is focused,...
                if (currentIndex === maxIndex) return firstPanel.heading.focus();
                // ... otherwise focus the next panel's heading
                return this.current.panels[currentIndex + 1].heading.focus();
            }
        }
    
        // Handle keydown events
        onKeyDown(event) {
            // Grab the target and key of the event and create a list of valid keys
            const { key, currentTarget } = event;
    
            // If a valid key has not been pressed, do nothing else
            if (
                !['Enter', ' ', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(key)
            )
                return;
    
            // Otherwise, find the heading's associated accordion,...
            this.setCurrentAccordion('heading', currentTarget);
            // ... prevent the default behavior
            event.preventDefault();
    
            switch (key) {
                // When the "Enter" or space key is pressed...
                case 'Enter':
                case ' ':
                    // ... toggle the panels
                    return this.togglePanels(event);
                // When the up or down arrow keys are pressed...
                case 'ArrowUp':
                case 'ArrowDown':
                    // ... focus a panel heading relative to the current target
                    return this.focusHeading({ key, currentTarget });
                // When the "Home" or "End" keys are pressed...
                case 'Home':
                case 'End':
                    // ... focus their respective panel heading
                    return this.focusHeading({ key });
            }
        }
    
        // Handle hash change events
        onHashChange(event) {
            // Grab the location hash...
            const { hash } = window.location,
                id = hash.substr(1),
                // ... and see if there's a valid target with that ID
                target = this.panels.find((element) => element.panel.id === id);
    
            // If no target was matched, do nothing else
            if (!target) return;
    
            // Set the current accordion...
            this.setCurrentAccordion('hash', target.panel, true);
            // ... and toggle the panels
            return this.togglePanels(event);
        }
    
        // Initialize the accordion panels
        initialize() {
            // First, get all references to the accordions, toggles, and panels
            this.getReferences();
    
            // If no accordions were found on the page, do nothing
            if (!this.accordions.length) return;
    
            // If no accordion panels were found on the page, do nothing
            if (!this.panels.length) return;
    
            // Bind to the toggles' click events if any exist
            if (this.toggles.length)
                this.toggles.forEach((toggle) =>
                    toggle.addEventListener('click', this.onClick)
                );
    
            // Finally, for each panel heading...
            this.panels.forEach(({ heading }) => {
                // ... and bind to its click/keydown events
                heading.addEventListener('click', this.onClick);
                heading.addEventListener('keydown', this.onKeyDown);
            });
    
            // Handle location hash panel toggling on page load...
            this.onHashChange();
            // ... and on every hash change after that
            window.addEventListener('hashchange', this.onHashChange);
        }
    }
    
    export default Accordion;
    
  • URL: /components/raw/accordion/index.js
  • Filesystem Path: components/accordion/index.js
  • Size: 16.2 KB

Accordion

The accordion component is a group of collapsable panels that hide or reveal their content when its header is clicked. When an accordion panel heading is clicked, all other accordion panels that are expanded will collapse. The accordion can be used with a single collapsible panel or multiple. When more than one collapsible panel is used within an accordion, a toggle button is added, allowing users to expand/collapse all accordion panels at once.

Markup

Whether you are using an accordion with a single panel or multiple, ensure that all panels are wrapped in a div.cwf-accordion__wrapper element. Each individual panel will have a div.cwf-accordion__panel element.

If you are using this component with a group of accordions ensure you are rendering the “Expand All / Collapse All” toggle button with a class of .cwf-accordion__toggle.

Javascript

Each accordion panel heading is listening for click events or keydown events when focused.

The following events will trigger the panel to expand or collapse:

  • A click
  • Pressing the “Space” key
  • Pressing the “Enter” key

The following keydown events will change panel heading focus:

  • Up arrow - Moves focus to the previous panel’s heading, unless on the first panel which will focus the last panel’s heading
  • Down arrow - Moves focus to the next panel’s heading, unless on the last panel which will focus the first panel’s heading
  • “Home” key - Focuses the first panel’s heading
  • “End” key - Focuses the last panel’s heading