Motion patterns Get motion.css

Patterns

Accordion and tabs

Native details that open to their real height while the plus folds into a minus, and tabs whose ink moves like an inchworm: the leading edge first, the trailing edge a beat later.

Open the demo alone
0.00s

Timeline loads with the demo.

Two ways to show one part of a page at a time. The accordion is native <details> that opens to its real height while the plus folds into a minus. The tabs have an inchworm ink: its leading edge moves to the new tab first and the trailing edge follows a beat later, so it stretches toward where you clicked and then gathers itself.

Use

<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="disclose/style.css">

<details class="disclose" name="faq">
  <summary>Can I return it? <span class="disclose-mark" aria-hidden="true"></span></summary>
  <p>Within 14 days, unworn with the tag on.</p>
</details>
<script src="disclose/tabs.js"></script>

<section data-tabs>
  <div class="tabs-list" role="tablist" aria-label="About this shirt">
    <button type="button" role="tab" id="t-a" aria-controls="p-a" aria-selected="true">Details</button>
    <button type="button" role="tab" id="t-b" aria-controls="p-b" aria-selected="false">Sizing</button>
    <span class="tabs-ink" aria-hidden="true"></span>
  </div>
  <div class="tabs-panels">
    <div role="tabpanel" id="p-a" aria-labelledby="t-a" tabindex="0">...</div>
    <div role="tabpanel" id="p-b" aria-labelledby="t-b" tabindex="0">...</div>
  </div>
</section>

Style .tabs-ink yourself (an underline, a pill behind the label); the pattern only moves it.

Parts

Class or attributeWhat it does
.disclose on <details>Height from 0 to auto, the answer rises in after the box opens
name="..." on several <details>Native: only one of them is open at a time
.disclose-markA plus that folds into a minus on the spring curve
[data-tabs] + tabs.jsARIA tabs: click, arrow keys, Home and End
.tabs-inkThe inchworm: leading edge first, trailing edge --ink-lag (100ms) later
.tabs-panelsPanels share one grid cell, so the box keeps the height of the tallest panel and never jumps. The new panel slides in from the side the ink moved toward

Do

  • Put the whole answer in the accordion panel. In Chromium browsers, find in page (Ctrl+F) opens a closed <details> that contains a match.
  • Keep tab labels to one or two words. The ink reads best over short, even steps.

Do not

  • Hide the only copy of a price, a deadline or a legal term inside a closed panel.
  • Use tabs for steps that must happen in order. That is a stepper, not tabs.

Reduced motion

The accordion opens and closes at once, the ink jumps to the new tab, and the panels swap with no slide.

Browser support

The accordion height uses interpolate-size and ::details-content (Chrome 131 and later). Other browsers open the panel at once, and the answer still fades in. The tabs work in every current browser.

style.css

/*
 * disclose: two ways to show one part of a page at a time, both answering a click.
 *
 * Accordion: native <details>. The height animates from 0 to auto (interpolate-size), the plus
 * folds into a minus on the spring, and the answer rises in a beat after the box has opened, so the
 * eye follows the opening first and the words second. Browsers without ::details-content open at once.
 */
.disclose { --disclose-dur: calc(var(--dur-base) * .7); interpolate-size: allow-keywords; }
.disclose::details-content {
  block-size: 0;
  overflow-y: clip;
  transition: block-size var(--disclose-dur) var(--ease-out), content-visibility var(--disclose-dur) allow-discrete;
}
.disclose[open]::details-content { block-size: auto; }
.disclose > :not(summary) {
  opacity: 0;
  translate: 0 8px;
  transition: opacity var(--dur-quick) var(--ease-out), translate var(--dur-quick) var(--ease-out);
}
.disclose[open] > :not(summary) {
  opacity: 1;
  translate: none;
  transition-duration: var(--disclose-dur);
  transition-delay: calc(var(--disclose-dur) * .3);
}
.disclose > summary { list-style: none; cursor: pointer; }
.disclose > summary::-webkit-details-marker { display: none; }
/* the mark: two bars. Open turns the upright bar flat, so the plus folds into a minus. */
.disclose-mark { position: relative; flex: none; width: 12px; height: 12px; }
.disclose-mark::before, .disclose-mark::after {
  content: ""; position: absolute; left: 0; top: 5px; width: 12px; height: 2px; border-radius: 1px; background: currentColor;
}
.disclose-mark::after { rotate: 90deg; transition: rotate var(--dur-base) var(--ease-spring); }
.disclose[open] .disclose-mark::after { rotate: 0deg; }

/*
 * Tabs: the inchworm. The ink under the chosen tab moves its leading edge first and lets the
 * trailing edge follow a beat later, so it stretches toward the new tab and then gathers itself.
 * tabs.js sets --ink-l and --ink-r (insets from the list's edges) and data-ink="left|right".
 * The new panel slides a short way in from the side the ink travelled toward while the old one
 * leaves the other way. The new one waits until the old one is mostly gone, so the words never overlap.
 */
.tabs-list { position: relative; isolation: isolate; }
.tabs-ink {
  --ink-dur: calc(var(--dur-base) * .5);
  --ink-lag: calc(var(--stagger) * 1.25);
  position: absolute; z-index: -1;
  left: var(--ink-l, 0); right: var(--ink-r, 100%);
  transition: left var(--ink-dur) var(--ease-out), right var(--ink-dur) var(--ease-out);
}
.tabs-list[data-ink="right"] .tabs-ink { transition-delay: var(--ink-lag), 0s; }
.tabs-list[data-ink="left"] .tabs-ink { transition-delay: 0s, var(--ink-lag); }
.tabs-list[data-ink="none"] .tabs-ink { transition: none; }

.js .tabs-panels { display: grid; }
.js .tabs-panels > [role="tabpanel"] { grid-area: 1 / 1; }
.js .tabs-panels > .is-off { visibility: hidden; }
.tabs-panels > .is-in { animation: tabs-in calc(var(--dur-base) * .6) var(--ease-out) calc(var(--dur-quick) * .6) both; }
.tabs-panels > .is-out { animation: tabs-out var(--dur-quick) var(--ease-in) both; pointer-events: none; }
@keyframes tabs-in { from { opacity: 0; translate: calc(var(--tabs-dir, 1) * 18px) 0; } }
@keyframes tabs-out { to { opacity: 0; translate: calc(var(--tabs-dir, 1) * -18px) 0; } }

@media (prefers-reduced-motion: reduce) {
  .disclose::details-content, .disclose > :not(summary), .disclose[open] > :not(summary), .disclose-mark::after { transition: none; }
  .tabs-ink { transition: none; }
  .tabs-panels > .is-in, .tabs-panels > .is-out { animation: none; }
}