Accordion Card

Why Make Another CSS Framework?

As you may know, there are many CSS frameworks (hundreds of them, and a lot of them are not maintained today). Everybody can choose one that suits their work style or project requirements. So why make another one? It is certainly not because we can do it better but because we want to do it our way. We want to be in control and make decisions.

It Is Opinionated

Each system is opinionated but on a different level; this is valid for Spruce too. We don’t want to vote for (strictly) any particular solution (because there is always more than one), but we will show you what we think is the best for us (and maybe for you too). We don’t believe there is a good or bad solution, but we can learn from any of them.

Coding Style Guide and Practices

Where it is possible, we use elements and/or attributes to style elements, but it is still a class-based framework.

@use 'sprucecss/scss/spruce' as *;

.accordion-list {
  @include layout-stack('s');
}

.accordion-card {
  $this: &;
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');

  &__title {
    font-size: font-size('h4');
    margin-block: 0;
    padding: spacer('m');

    &:has(#{$this}__toggle) {
      padding: 0;
    }
  }

  &__toggle {
    @include clear-btn;
    align-items: center;
    display: flex;
    gap: spacer('m');
    inline-size: 100%;
    justify-content: space-between;
    padding: spacer('m');
    text-align: start;

    &:hover {
      color: color('primary');
    }

    &:focus-visible {
      outline: none;

      #{$this}__icon {
        @include short-ring('button', 'primary');
      }
    }

    &[aria-expanded='true'] {
      svg {
        rotate: 90deg;
      }

      .horizontal-line {
        opacity: 0;
      }
    }
  }

  &__icon {
    --size: 1.75rem;
    align-items: center;
    background-color: color('primary-background', 'btn');
    block-size: var(--size);
    border-radius: radius('sm');
    color: color('primary-foreground', 'btn');
    display: flex;
    flex-shrink: 0;
    inline-size: var(--size);
    justify-content: center;

    svg {
      --size: 1.25rem;
      @include transition;
      block-size: var(--size);
      inline-size: var(--size);
    }

    .horizontal-line {
      @include transition;
    }
  }

  &__content {
    @include transition($property: 'grid-template-rows, padding-block-end');
    display: grid;

    // Without JavaScript, the content stays open; the script collapses it with [inert].
    grid-template-rows: 1fr;
    padding-block-end: spacer('m');
    padding-inline: spacer('m');

    &[inert] {
      grid-template-rows: 0fr;
      padding-block-end: 0;
    }

    > div {
      overflow: hidden;
      @include layout-stack('xs');

      * + h2,
      * + h3,
      * + h4,
      * + h5 {
        margin-block-start: spacer('s');
      }
    }

  }
}

@include motion-reduce {
  .accordion-card__content,
  .accordion-card__icon svg {
    transition: none;
  }
}
<div class="accordion-list" data-accordion>
    <div class="accordion-card">
        <h3 class="accordion-card__title">
            Why Make Another CSS Framework?
        </h3>
        <div class="accordion-card__content">
            <div>
                <p>As you may know, there are many CSS frameworks (hundreds of them, and a lot of them are not maintained today). Everybody can choose one that suits their work style or project requirements. So why make another one? It is certainly not because we can do it better but because we want to do it our way. We want to be in control and make decisions.</p>
            </div>
        </div>
    </div>
    <div class="accordion-card">
        <h3 class="accordion-card__title">
            It Is Opinionated
        </h3>
        <div class="accordion-card__content">
            <div>
                <p>Each system is opinionated but on a different level; this is valid for Spruce too. We don’t want to vote for (strictly) any particular solution (because there is always more than one), but we will show you what we think is the best for us (and maybe for you too). We don’t believe there is a good or bad solution, but we can learn from any of them.</p>
            </div>
        </div>
    </div>
    <div class="accordion-card">
        <h3 class="accordion-card__title">
            Coding Style Guide and Practices
        </h3>
        <div class="accordion-card__content">
            <div>
                <p>Where it is possible, we use elements and/or attributes to style elements, but it is still a class-based framework.</p>
            </div>
        </div>
    </div>
</div>
(() => {
  let count = 0;

  document.querySelectorAll('[data-accordion]').forEach((list) => {
    // data-accordion="multiple" lets more than one card stay open.
    const multiple = list.dataset.accordion === 'multiple';
    const cards = [...list.querySelectorAll('.accordion-card')];

    const toggle = (card, open) => {
      card.querySelector('.accordion-card__toggle').setAttribute('aria-expanded', String(open));
      card.querySelector('.accordion-card__content').inert = !open;
    };

    cards.forEach((card) => {
      const heading = card.querySelector('.accordion-card__title');
      const content = card.querySelector('.accordion-card__content');
      const button = document.createElement('button');

      count += 1;
      content.id ||= `accordion-content-${count}`;

      // The heading keeps its level; the button inside it gets the text and the state.
      button.className = 'accordion-card__toggle';
      button.type = 'button';
      button.setAttribute('aria-controls', content.id);
      button.innerHTML = '<span class="accordion-card__label"></span><span class="accordion-card__icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line class="vertical-line" x1="12" y1="5" x2="12" y2="19"></line><line class="horizontal-line" x1="5" y1="12" x2="19" y2="12"></line></svg></span>';
      button.querySelector('.accordion-card__label').textContent = heading.textContent.trim();
      heading.replaceChildren(button);

      // Open the card that's linked from the URL (#id of the content).
      toggle(card, window.location.hash === `#${content.id}`);

      button.addEventListener('click', () => {
        const open = button.getAttribute('aria-expanded') !== 'true';

        if (open && !multiple) {
          cards.filter((other) => other !== card).forEach((other) => toggle(other, false));
        }

        toggle(card, open);
      });
    });
  });
})();

Accordion Card

A list of collapsible cards for FAQs and long, optional content: only the titles show, and each card opens on demand with a smooth height animation.

Anatomy

  • .accordion-list with data-accordion groups the cards; data-accordion="multiple" lets more than one stay open.
  • .accordion-card has a heading (__title) and the content (__content, with an inner div).
  • The script puts a .accordion-card__toggle button (with the __label and the __icon) into the heading.

Accessibility

  • It follows the Accordion pattern: the button inside the heading has aria-expanded and aria-controls, so the heading structure stays and the state is announced.
  • A collapsed content gets the inert attribute: it's hidden from screen readers and its links can't be focused (aria-hidden alone left them focusable).
  • Without JavaScript, every card stays open and readable; the script collapses them.
  • The focus ring appears on the icon, only for keyboard focus (:focus-visible).

Technical Details

  • The height animates with grid-template-rows from 0fr to 1fr, so no height has to be measured; it's turned off for reduced motion.
  • Each list works on its own: opening a card only closes the others in the same list.
  • If the URL hash is the id of a content, that card opens on load, so you can link to a question.
  • No dependencies. For a no-JavaScript option, the native details element (styled by Spruce) is a good alternative.

Resources

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.