From 0f5d0e200f63fce7141111c58ed89acc9ba4c7b6 Mon Sep 17 00:00:00 2001
From: SandObserver <260779319+SandObserver@users.noreply.github.com>
Date: Sun, 13 Sep 2026 14:58:07 -0400
Subject: [PATCH] Glide the docs navigation highlights and animate disclosures
Also add spacing above service list details and centre the copy tooltip on its button.
---
src/components/Head.astro | 1 +
src/scripts/motion.ts | 157 ++++++++++++++++++++++++++++++++++++++
src/styles/docs.css | 63 ++++++++++++++-
3 files changed, 220 insertions(+), 1 deletion(-)
create mode 100644 src/scripts/motion.ts
diff --git a/src/components/Head.astro b/src/components/Head.astro
index f237756..e319cc8 100644
--- a/src/components/Head.astro
+++ b/src/components/Head.astro
@@ -60,3 +60,4 @@ const breadcrumb = {
observer.observe(box);
}
+
diff --git a/src/scripts/motion.ts b/src/scripts/motion.ts
new file mode 100644
index 0000000..ab56302
--- /dev/null
+++ b/src/scripts/motion.ts
@@ -0,0 +1,157 @@
+const FINE = '(hover: hover) and (pointer: fine)';
+const REDUCE = '(prefers-reduced-motion: reduce)';
+const EASE = 'cubic-bezier(.32,.72,0,1)';
+const REVEAL_MS = 200;
+
+const HOVER_LISTS = [
+ { container: '.sidebar-content', item: 'a, summary' },
+ { container: 'starlight-toc nav', item: 'a' },
+];
+const DISCLOSURES = '#starlight__sidebar details, details.cl-item, details.sy-disc';
+
+const measurable = (el: Element) => {
+ const r = el.getBoundingClientRect();
+ return r.width > 0 && r.height > 0;
+};
+
+const ensurePill = (container: Element, variant: string) => {
+ let pill = container.querySelector(`:scope > .sy-mp--${variant}`);
+ if (!pill) {
+ pill = document.createElement('div');
+ pill.className = `sy-mp sy-mp--${variant}`;
+ pill.setAttribute('aria-hidden', 'true');
+ container.prepend(pill);
+ }
+ return pill;
+};
+
+const placePill = (container: Element, pill: HTMLElement, item: Element) => {
+ if (!measurable(container) || !measurable(item)) {
+ pill.classList.remove('on');
+ return;
+ }
+ const cr = container.getBoundingClientRect();
+ const ir = item.getBoundingClientRect();
+ const cs = getComputedStyle(item);
+ const rtl = cs.direction === 'rtl';
+ const inline = (rtl ? cr.right - ir.right : ir.left - cr.left) - container.clientLeft;
+ const block = ir.top - cr.top - container.clientTop;
+ pill.style.borderRadius = cs.borderRadius;
+ pill.style.width = `${ir.width}px`;
+ pill.style.height = `${ir.height}px`;
+ pill.style.transform = `translate3d(${rtl ? -inline : inline}px,${block}px,0)`;
+ if (!pill.classList.contains('on')) {
+ pill.getBoundingClientRect();
+ pill.classList.add('on');
+ }
+};
+
+const hovered = new Map();
+const tocs = new Set();
+
+const refresh = () => {
+ for (const [container, item] of hovered) {
+ const pill = ensurePill(container, 'hover');
+ if (item?.isConnected) placePill(container, pill, item);
+ else pill.classList.remove('on');
+ }
+ for (const nav of tocs) {
+ const current = nav.querySelector('a[aria-current="true"]');
+ if (current) placePill(nav, ensurePill(nav, 'toc'), current);
+ }
+};
+
+if (matchMedia(FINE).matches) {
+ for (const cfg of HOVER_LISTS) {
+ for (const container of document.querySelectorAll(cfg.container)) {
+ container.addEventListener('pointerover', (e) => {
+ if ((e as PointerEvent).pointerType !== 'mouse') return;
+ const item = (e.target as Element).closest(cfg.item);
+ if (!item || !container.contains(item)) return;
+ hovered.set(container, item);
+ refresh();
+ });
+ container.addEventListener('pointerleave', () => {
+ hovered.set(container, null);
+ refresh();
+ });
+ }
+ }
+}
+
+for (const nav of document.querySelectorAll('starlight-toc nav')) {
+ tocs.add(nav);
+ new MutationObserver(refresh).observe(nav, {
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['aria-current'],
+ });
+}
+
+const resizeObserver = new ResizeObserver(refresh);
+for (const cfg of HOVER_LISTS) {
+ for (const container of document.querySelectorAll(cfg.container)) resizeObserver.observe(container);
+}
+refresh();
+
+const running = new WeakMap();
+
+const heightWhen = (details: HTMLDetailsElement, open: boolean) => {
+ details.open = open;
+ return details.getBoundingClientRect().height;
+};
+
+const animateDisclosure = (details: HTMLDetailsElement, open: boolean) => {
+ const from = details.getBoundingClientRect().height;
+ for (const a of running.get(details) ?? []) a.cancel();
+ const closed = heightWhen(details, false);
+ const expanded = heightWhen(details, true);
+ const duration = open ? REVEAL_MS : REVEAL_MS * 0.8;
+ details.style.overflow = 'hidden';
+
+ const size = details.animate(
+ { height: [`${from}px`, `${open ? expanded : closed}px`] },
+ { duration, easing: EASE },
+ );
+ const content = [...details.children].filter((el) => el.tagName !== 'SUMMARY');
+ const fades = content.map((el) =>
+ el.animate(
+ { opacity: open ? [0, 1] : [1, 0] },
+ open
+ ? { duration: REVEAL_MS * 0.55, delay: REVEAL_MS * 0.25, easing: 'linear', fill: 'backwards' }
+ : { duration: REVEAL_MS * 0.32, easing: 'linear', fill: 'forwards' },
+ ),
+ );
+ running.set(details, [size, ...fades]);
+
+ let frame = requestAnimationFrame(function follow() {
+ refresh();
+ frame = requestAnimationFrame(follow);
+ });
+ size.onfinish = () => {
+ cancelAnimationFrame(frame);
+ if (!open) details.open = false;
+ for (const f of fades) f.cancel();
+ details.style.overflow = '';
+ running.delete(details);
+ refresh();
+ };
+ size.oncancel = () => cancelAnimationFrame(frame);
+};
+
+/* Bubble phase on the document, never capture. Starlight saves a sidebar group
+ as the inverse of its open state when the click reaches the sidebar. Changing
+ it before then saves the wrong state. */
+document.addEventListener('click', (e) => {
+ if (e.defaultPrevented || matchMedia(REDUCE).matches) return;
+ const target = e.target as Element;
+ const summary = target.closest?.('summary');
+ if (!summary || target.closest('a')) return;
+ const details = summary.parentElement;
+ if (!(details instanceof HTMLDetailsElement) || !details.matches(DISCLOSURES)) return;
+ if (details.querySelector(':scope > summary') !== summary) return;
+ e.preventDefault();
+ const opening = running.has(details) ? details.dataset.syClosing === 'true' : !details.open;
+ details.dataset.syClosing = String(!opening);
+ animateDisclosure(details, opening);
+});
diff --git a/src/styles/docs.css b/src/styles/docs.css
index 7b4240f..98240c6 100644
--- a/src/styles/docs.css
+++ b/src/styles/docs.css
@@ -187,6 +187,10 @@
border-radius: var(--sy-radius-lg);
border: 1px solid var(--sl-color-hairline);
}
+.expressive-code .frame .copy > [aria-live] {
+ display: flex;
+ align-items: center;
+}
.sl-markdown-content table {
border-collapse: collapse;
@@ -636,7 +640,64 @@ button.sy-iconbtn:focus-visible {
}
}
+.sidebar-content,
+starlight-toc nav {
+ position: relative;
+}
+.sidebar-content :where(a, summary),
+starlight-toc nav a {
+ position: relative;
+}
+#starlight__sidebar summary {
+ border-radius: 0.25rem;
+}
+/* pointer-events: none, or the pill swallows every hover and click in the list. */
+.sy-mp {
+ position: absolute;
+ top: 0;
+ inset-inline-start: 0;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.12s ease;
+}
+.sy-mp.on {
+ opacity: 1;
+ transition:
+ opacity 0.12s ease,
+ transform 0.19s cubic-bezier(0.22, 1, 0.36, 1),
+ width 0.19s cubic-bezier(0.22, 1, 0.36, 1),
+ height 0.19s cubic-bezier(0.22, 1, 0.36, 1);
+}
+.sy-mp--hover {
+ background: var(--sy-overlay);
+}
+.sy-mp--toc {
+ background: color-mix(in srgb, var(--sl-color-text-accent) 14%, transparent);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ @view-transition {
+ navigation: auto;
+ }
+ :root {
+ view-transition-name: none;
+ }
+}
+@media (prefers-reduced-motion: no-preference) and (min-width: 50em) {
+ #starlight__sidebar a[aria-current='page'] {
+ view-transition-name: sy-current-page;
+ }
+}
+::view-transition-group(sy-current-page) {
+ animation-duration: 0.32s;
+ animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
+}
+
@media (prefers-reduced-motion: reduce) {
+ .sy-mp,
+ .sy-mp.on {
+ transition: none;
+ }
}
.sy-shot {
@@ -1106,7 +1167,7 @@ button.sy-iconbtn:focus-visible {
}
.sl-markdown-content .sy-disc__body {
- padding: 0 16px 16px;
+ padding: 8px 16px 16px;
}
.sl-markdown-content .sy-disc__fields {
display: grid;