Button loading spinnerWeb · App
Target
Implement the "Accordion expand" effect in vanilla HTML/CSS/JS. Context: A disclosure panel grows from zero to its natural height while its chevron rotates, with no fixed max-height hack. Trigger: the element is pressed (pointerdown → pointerup). Timeline: time-based (duration + easing). Motion tokens (normal): - grid-template-rows (panel): 0fr → 1fr - rotate (chevron): 0deg → 180deg - opacity (content): 0 → 1 - duration: 260ms - easing: cubic-bezier(0.2, 0.8, 0.2, 1) - note: height change is a layout animation by nature; keep it to one panel at a time Motion tokens (prefers-reduced-motion): - grid-template-rows: 0fr → 1fr instantly - opacity (content): 0 → 1 over 150ms - duration: 0ms Technique: prefer pure CSS transitions/animations; use JS only to toggle classes. Acceptance: - animate only transform and opacity unless the spec lists other properties - no layout shift; content is visible without the animation - when prefers-reduced-motion is set, use the reduced tokens only - no max-height guess; panel animates to its true content height - button has aria-expanded and controls the panel id
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-accordion-expand" data-fx-root>
<div class="fx-acc">
<h3 class="fx-h"><button type="button" class="fx-trigger" aria-expanded="false" aria-controls="fx-acc-p1">Shipping & returns<svg class="fx-chev" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button></h3>
<div class="fx-panel" id="fx-acc-p1"><div class="fx-inner"><div class="fx-p"></div><div class="fx-p"></div><div class="fx-p fx-short"></div></div></div>
<h3 class="fx-h"><button type="button" class="fx-trigger" aria-expanded="false">Warranty</button></h3>
</div>
</div>
demo.css
.fx-accordion-expand { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.fx-accordion-expand .fx-acc { width: 70%; max-width: 360px; border: 1px solid var(--fx-muted, #e4e4e7); border-radius: 12px; background: #fff; overflow: hidden; }
.fx-accordion-expand .fx-h { margin: 0; }
.fx-accordion-expand .fx-trigger { display: flex; align-items: center; justify-content: space-between; width: 100%; height: 44px; padding: 0 16px; border: 0; background: transparent; color: var(--fx-ink, #18181b); font: 500 14px/1 Inter, system-ui, sans-serif; cursor: pointer; text-align: left; }
.fx-accordion-expand .fx-h + .fx-h { border-top: 1px solid var(--fx-muted, #e4e4e7); }
.fx-accordion-expand .fx-chev { transition: transform 260ms cubic-bezier(.2, .8, .2, 1); }
.fx-accordion-expand .fx-trigger[aria-expanded="true"] .fx-chev { transform: rotate(180deg); }
.fx-accordion-expand .fx-panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 260ms cubic-bezier(.2, .8, .2, 1); }
.fx-accordion-expand .fx-panel.is-open { grid-template-rows: 1fr; }
.fx-accordion-expand .fx-inner { overflow: hidden; display: flex; flex-direction: column; gap: 8px; padding: 0 16px; opacity: 0; transition: opacity 200ms ease-out, padding 260ms cubic-bezier(.2, .8, .2, 1); }
.fx-accordion-expand .fx-panel.is-open .fx-inner { opacity: 1; padding: 0 16px 14px; }
.fx-accordion-expand .fx-p { height: 8px; border-radius: 4px; background: var(--fx-muted, #e4e4e7); }
.fx-accordion-expand .fx-short { width: 60%; }
@media (prefers-reduced-motion: reduce) { .fx-accordion-expand .fx-panel, .fx-accordion-expand .fx-chev, .fx-accordion-expand .fx-inner { transition: opacity 150ms ease-out; } }
demo.js
export default function mount(root) {
const btn = root.querySelector('[aria-controls]'), panel = root.querySelector('.fx-panel');
const set = (open) => { btn.setAttribute('aria-expanded', String(open)); panel.classList.toggle('is-open', open); };
btn.addEventListener('click', () => set(btn.getAttribute('aria-expanded') !== 'true'));
let t1, t2;
return {
play() { clearTimeout(t1); clearTimeout(t2); set(false); t1 = setTimeout(() => set(true), 400); t2 = setTimeout(() => set(false), 2000); },
stop() { clearTimeout(t1); clearTimeout(t2); set(false); },
};
}
Inspired by css-tricks.com — CSS-Tricks: grid auto-height transitions. The preview is an original re-creation; the effect name belongs to its author.
`grid-template-rows: 0fr → 1fr` is the only pure-CSS way to animate to `auto` height without JS measuring. It is a layout animation, so it is exempt from the transform-only rule but should never run on many panels at once.
Added 2026-11-09 · Report an issue