CrystalPage
The page shell — where fixed elements go, and where anchors land
CrystalPage exists to own three things no individual site should have to remember: where fixed elements sit relative to a smooth wrapper, the anchor offset a sticky bar demands, and the skip link. It does not implement smoothing — it composes the existing CrystalScrollSmoother.
smooth defaults to OFF
Smooth scrolling changes scroll speed, which is NN/g’s literal definition of scrolljacking. Their findings: a majority of participants experienced at least mild disorientation, some interpreted it as a bug, task-driven users were markedly less patient than exploratory ones, text plus altered scroll was the worst combination, and mobile magnified all of it. There is also a nasty second-order effect — participants escaped a confusing scrolljack via the sticky navigation, and on mobile, where the nav was collapsed, they could not.
So it is opt-in per site, and never default-on for commerce or documentation. smoothTouch also stays off, matching GSAP’s own default — a site tested on a laptop is not tested at all on a phone.
// native scroll — the default
<CrystalPage nav={<CrystalNav links={links} />}>…</CrystalPage>
// opt in, deliberately, per site
<CrystalPage smooth={{ duration: 1.2 }} …>…</CrystalPage>Why nav and footer are props, not children
ScrollSmoother’s docs are explicit: “position: fixed should be outside the wrapper.” The reason is CSS, not GSAP — a transform on the content creates a new containing block, so anything fixed inside it attaches to the content rather than the viewport and scrolls away looking like a GSAP bug. Passing the bar as a prop makes the correct structure the only structure a site can build.
<div data-crystal-page>
<a class="crystal-page-skip" href="#crystal-main">Skip to content</a>
<div>{nav}</div> ← OUTSIDE the wrapper
<div id="smooth-wrapper">
<div id="smooth-content">
<div id="crystal-main">{children}</div>
</div>
</div>
{footer} ← OUTSIDE the wrapper
</div>The anchor offset, broken beside fixed
A sticky bar is the textbook failure for WCAG 2.2 SC 2.4.11 — failure F110 names this exact case. Click the jump link in each panel. The broken one lands the heading underneath the bar; the fixed one lands it clear of it.
⚠️ The half-fix worth knowing: scroll-margin-top has to be on whatever carries the id. Putting it on the <h3> when the id is on the enclosing section does nothing at all.
Section 1
Filler so there is something to scroll past. The bar above is 72px tall, so an un-offset jump lands this heading 72px under it — invisible, and for a keyboard user, focused on something they cannot see.
Section 2
Filler so there is something to scroll past. The bar above is 72px tall, so an un-offset jump lands this heading 72px under it — invisible, and for a keyboard user, focused on something they cannot see.
Section 3 — the jump target
Filler so there is something to scroll past. The bar above is 72px tall, so an un-offset jump lands this heading 72px under it — invisible, and for a keyboard user, focused on something they cannot see.
Section 4
Filler so there is something to scroll past. The bar above is 72px tall, so an un-offset jump lands this heading 72px under it — invisible, and for a keyboard user, focused on something they cannot see.
The skip link is on by default
WCAG 2.4.1 Bypass Blocks applies to every site, and no site remembers it — so CrystalPage ships one pointing at the content it wraps. It is visually hidden until focused. Tab into the live shell below from the start and it is the first thing you reach.
The whole shell, assembled
Bar, body and footer — the three components together, which is how every site in the fleet is meant to be built.
One bar, one footer, every site in the fleet.
Same functionality everywhere, different design everywhere. Every visual value in this bar and this footer resolves to a Crystal token, so switching the theme on this documentation site re-skins both of them without a line of component code changing.
Same functionality everywhere, different design everywhere. Every visual value in this bar and this footer resolves to a Crystal token, so switching the theme on this documentation site re-skins both of them without a line of component code changing.
Same functionality everywhere, different design everywhere. Every visual value in this bar and this footer resolves to a Crystal token, so switching the theme on this documentation site re-skins both of them without a line of component code changing.
Same functionality everywhere, different design everywhere. Every visual value in this bar and this footer resolves to a Crystal token, so switching the theme on this documentation site re-skins both of them without a line of component code changing.
Same functionality everywhere, different design everywhere. Every visual value in this bar and this footer resolves to a Crystal token, so switching the theme on this documentation site re-skins both of them without a line of component code changing.
<CrystalPage
nav={<CrystalNav links={links} scrollBehavior="hide" />}
footer={<CrystalFooter columns={columns} legal={legal} />}
>
<Hero />
<section id="work">…</section>
</CrystalPage>