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.
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
<linkrel="stylesheet"href="tokens.css">
<linkrel="stylesheet"href="disclose/style.css">
<detailsclass="disclose"name="faq">
<summary>Can I return it? <spanclass="disclose-mark"aria-hidden="true"></span></summary>
<p>Within 14 days, unworn with the tag on.</p>
</details>
Style .tabs-ink yourself (an underline, a pill behind the label); the pattern only moves it.
Parts
Class or attribute
What 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-mark
A plus that folds into a minus on the spring curve
[data-tabs] + tabs.js
ARIA tabs: click, arrow keys, Home and End
.tabs-ink
The inchworm: leading edge first, trailing edge --ink-lag (100ms) later
.tabs-panels
Panels 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: 08px;
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; }
}