diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 5f6ea80f42d..e2c10fb937c 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -373,23 +373,33 @@ aside#nd-sidebar [data-radix-scroll-area-viewport] { Safe because the grid columns are explicit (`0px 300px 1fr 268px 0px`), so removing the placeholder from flow leaves its track intact. `left`/`width` are restated because a fixed box no longer derives them from its grid cell, - and `top`/`height` already come from fumadocs' own utility classes. */ + and `height` already comes from fumadocs' own utility classes. + + Anchoring to `bottom` rather than `top` is what keeps the footer off it: the + offset is how far the footer currently reaches into the viewport (published + by `FooterOverlapProbe`), so the sidebar keeps its full height and slides up + out of view as the footer arrives, the way it did before it was pinned. With + no footer on screen the offset is 0 and this resolves back to top: 92px. */ [data-sidebar-placeholder] { position: fixed !important; left: var(--sidebar-offset); width: var(--fd-sidebar-width); + top: auto !important; + bottom: var(--docs-footer-overlap, 0px) !important; } /* Sidebar divider line — pinned for the same reason, and so it stays glued to the sidebar's right edge. Being fixed takes it out of #nd-docs-layout's grid entirely, so it needs no grid placement and cannot skew a content cell; its - position comes from `left`/`top` alone. */ + position comes from `left`/`top`/`bottom` alone. Unlike the sidebar it is + shortened rather than slid, so it runs from the navbar down to the footer's + top border and the two meet instead of the line stopping short. */ #nd-docs-layout::before { content: ""; display: block; position: fixed; top: 92px; /* below navbar */ - height: calc(100dvh - 92px); + bottom: var(--docs-footer-overlap, 0px); left: calc(var(--sidebar-offset) + var(--fd-sidebar-width)); width: 1px; background-color: var(--surface-active); diff --git a/apps/docs/components/footer/footer-overlap.tsx b/apps/docs/components/footer/footer-overlap.tsx new file mode 100644 index 00000000000..68deafe2d88 --- /dev/null +++ b/apps/docs/components/footer/footer-overlap.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useEffect, useRef } from 'react' + +const OVERLAP_PROPERTY = '--docs-footer-overlap' + +/** + * Publishes how many pixels of the viewport bottom the footer currently covers. + * + * The docs sidebar and its divider are pinned to the viewport, so on their own + * they would run underneath the footer at the end of the page. Both read this as + * their `bottom` and stop at the footer's top edge instead — the sidebar slides + * away with the page and the divider meets the footer's border. + * + * It is deliberately measured against the viewport rather than the document, so + * the value only moves while the footer is actually on screen — a content-height + * change higher up the page cannot disturb the sidebar at all. That was the + * regression #6301 fixed and this must not undo. + */ +export function FooterOverlapProbe() { + const sentinelRef = useRef(null) + + useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel) return + + const root = document.documentElement + let frame = 0 + let published = -1 + + const measure = () => { + frame = 0 + const overlap = Math.max( + 0, + Math.round(window.innerHeight - sentinel.getBoundingClientRect().top) + ) + if (overlap === published) return + published = overlap + root.style.setProperty(OVERLAP_PROPERTY, `${overlap}px`) + } + + const schedule = () => { + if (frame) return + frame = requestAnimationFrame(measure) + } + + measure() + window.addEventListener('scroll', schedule, { passive: true }) + window.addEventListener('resize', schedule) + + const observer = new ResizeObserver(schedule) + observer.observe(document.body) + + return () => { + if (frame) cancelAnimationFrame(frame) + window.removeEventListener('scroll', schedule) + window.removeEventListener('resize', schedule) + observer.disconnect() + root.style.removeProperty(OVERLAP_PROPERTY) + } + }, []) + + return ( +
+ ) +} diff --git a/apps/docs/components/footer/footer.tsx b/apps/docs/components/footer/footer.tsx index 75896de0aed..4009cc1de72 100644 --- a/apps/docs/components/footer/footer.tsx +++ b/apps/docs/components/footer/footer.tsx @@ -1,4 +1,5 @@ import Link from 'next/link' +import { FooterOverlapProbe } from '@/components/footer/footer-overlap' import { SimWordmark } from '@/components/ui/sim-logo' import { SIM_SITE_URL } from '@/lib/urls' @@ -133,13 +134,17 @@ function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) /** * Site footer. * - * `relative z-[22]` stacks it above the docs sidebar (z-20) and that sidebar's - * divider (z-21), both of which are pinned to the viewport, so the footer slides - * over them at the end of the page instead of being drawn through. + * The docs sidebar and its divider are pinned to the viewport, so they would run + * underneath a full-bleed footer at the end of the page. `FooterOverlapProbe` + * publishes how far the footer reaches into the viewport and both stop there + * instead. `relative z-[22]` stacks the footer above the sidebar (z-20) and its + * divider (z-21) so that, before the probe's first measurement, the footer covers + * them rather than being drawn through. */ export function Footer() { return (