From 383823a48e7e5a5b3d13a87b3b81475bf06d4994 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 19 May 2026 02:19:44 +0000 Subject: feat(details): animate details open/close via Web Animations API A document-level click delegate intercepts clicks and animates the parent
height instead of relying on the browser's instant snap. Initial attempt used the CSS pseudo-element approach (::details-content + interpolate-size: allow-keywords). Both features have shipped at different times across browsers (Safari < 18.2 has neither, Safari 18.2 to 25 only has the pseudo) and degrade in distinct broken ways. JS via WAAPI works in every browser that has shipped Web Animations. Closed height is computed from the summary's offsetHeight plus the details element's vertical padding and border (read from getComputedStyle), so the animation end-state matches the natural collapsed height regardless of per-element padding tweaks (details-unstyled, card variants, etc). Earlier draft animated to summary.offsetHeight only, which undershot by 2 * padding and caused the element to clip text before snapping back to its resting height. Respects prefers-reduced-motion (bypass to native toggle). Uses a WeakMap so rapid toggles cancel the in-flight animation cleanly. Duration 240ms / cubic-bezier(0.22,1,0.36,1) matches the panel-class motion token used elsewhere. --- src/routes/+layout.svelte | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) (limited to 'src/routes') diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 625f58b1..8c36bb0f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -101,6 +101,55 @@ const handleScroll = () => { if (isMenuOpen) isMenuOpen = false; }; +const detailsAnimations = new WeakMap(); + +const animateDetails = (e: MouseEvent) => { + const summary = (e.target as HTMLElement | null)?.closest("summary"); + if (!summary) return; + const details = summary.parentElement as HTMLDetailsElement | null; + if (!details || details.tagName !== "DETAILS") return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + e.preventDefault(); + detailsAnimations.get(details)?.cancel(); + + const detailsStyle = getComputedStyle(details); + const closedHeight = + summary.offsetHeight + + parseFloat(detailsStyle.paddingTop) + + parseFloat(detailsStyle.paddingBottom) + + parseFloat(detailsStyle.borderTopWidth) + + parseFloat(detailsStyle.borderBottomWidth); + const startHeight = details.offsetHeight; + + if (details.open) { + details.style.overflow = "hidden"; + const animation = details.animate( + { height: [`${startHeight}px`, `${closedHeight}px`] }, + { duration: 240, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }, + ); + detailsAnimations.set(details, animation); + animation.onfinish = () => { + details.open = false; + details.style.overflow = ""; + detailsAnimations.delete(details); + }; + } else { + details.style.overflow = "hidden"; + details.open = true; + const fullHeight = details.offsetHeight; + const animation = details.animate( + { height: [`${closedHeight}px`, `${fullHeight}px`] }, + { duration: 240, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }, + ); + detailsAnimations.set(details, animation); + animation.onfinish = () => { + details.style.overflow = ""; + detailsAnimations.delete(details); + }; + } +}; + onMount(async () => { if (browser) { if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) { @@ -223,6 +272,8 @@ $: { on:keydown={(e) => { if (e.key === 'Escape' && isMenuOpen) isMenuOpen = false; }} on:click={(e) => { if (isMenuOpen && !(e.target as HTMLElement).closest('.header')) isMenuOpen = false; + + animateDetails(e); }} /> -- cgit v1.2.3