Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { IBM_Plex_Sans } from "next/font/google";
import { NuqsAdapter } from "nuqs/adapters/next/app";

import { ThemeProvider } from "@/components/providers";
import { SmoothScroll } from "@/components/smooth-scroll";
import { siteConfig } from "@/config/site";
import { brandFont } from "@/lib/brand-font";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -92,6 +93,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
>
<head />
<body className={cn("min-h-screen bg-background antialiased", ibmPlex.className)}>
<SmoothScroll />
<NuqsAdapter>
<ThemeProvider
attribute="class"
Expand Down
66 changes: 66 additions & 0 deletions components/smooth-scroll.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import Lenis from "lenis";
import { cancelFrame, frame } from "motion/react";
import { useEffect } from "react";

import "lenis/dist/lenis.css";

/**
* Site-wide smooth wheel scrolling.
*
* Wheel input is interpolated so scroll-linked animations move continuously instead of in
* ~100px wheel steps. Touch and keyboard keep native scrolling. Lenis advances on Motion's
* frame loop, so scroll position and Motion animations share one clock per frame.
*/
export function SmoothScroll() {
useEffect(() => {
const lenis = new Lenis({
// Fraction of the remaining distance covered each frame. Higher feels tighter.
lerp: 0.14,
wheelMultiplier: 1.1,
// Sidebars, code blocks, menus, and contained previews keep their own scrolling.
allowNestedScroll: true,
prevent: (node) => node.closest("[data-lenis-prevent], [role='dialog']") !== null,
// Same-page hash links scroll through Lenis, so an in-flight wheel glide can't pull
// the page back from the heading. Targets honor scroll-margin like a native jump.
anchors: true,
stopInertiaOnNavigate: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle same-page anchors while Lenis is settling

stopInertiaOnNavigate only resets Lenis for links whose pathname changes, while anchors remains disabled. If a user clicks the plain hash links in components/toc.tsx or the skip link while wheel interpolation is still running, the browser performs the native anchor jump but Lenis remains in its smooth-scrolling state and the next frame writes the previous animated position, pulling the page away from the requested heading. Enable Lenis anchor handling or explicitly reset it for same-page hash navigation.

Useful? React with 👍 / 👎.

});

if (process.env.NODE_ENV !== "production") {
// Handy for inspecting scroll state from the console during development.
(window as unknown as { __lenis?: Lenis }).__lenis = lenis;
}

const update = ({ timestamp }: { timestamp: number }) => lenis.raf(timestamp);
frame.update(update, true);

// Lenis must not keep scrolling the page under a scroll lock. Radix dialogs and menus
// set `data-scroll-locked`; useLockBody (SplitReveal) sets inline overflow and position.
const html = document.documentElement;
const body = document.body;
const isLocked = () =>
body.hasAttribute("data-scroll-locked") ||
body.style.position === "fixed" ||
body.style.overflow === "hidden" ||
html.style.overflow === "hidden";
const syncLock = () => {
const locked = isLocked();
if (locked && !lenis.isStopped) lenis.stop();
else if (!locked && lenis.isStopped) lenis.start();
};
const observer = new MutationObserver(syncLock);
observer.observe(body, { attributes: true, attributeFilter: ["data-scroll-locked", "style"] });
observer.observe(html, { attributes: true, attributeFilter: ["style"] });
syncLock();

return () => {
observer.disconnect();
cancelFrame(update);
lenis.destroy();
};
}, []);

return null;
}
11 changes: 10 additions & 1 deletion content/docs/changelog/2026-10.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: October 2026
description: Aperture Reveal, a scroll-driven section where a photograph opens out of the headline, plus a two-chapter demo.
description: Aperture Reveal, a scroll-driven section where a photograph opens out of the headline, a two-chapter demo, smooth wheel scrolling across the site, and house motion tokens.
date: 2026-10-01
---

Expand All @@ -18,3 +18,12 @@ Added [Aperture Reveal](/docs/scroll/aperture-reveal), a scroll-driven section b
## Demos

Added [Dawn climb · two chapters](/demo/scroll/aperture-reveal-demo-001) to the Scroll group: a dark night chapter, a route-details band, and a light sunrise chapter, all on page scroll.

## Site

Added smooth wheel scrolling with [Lenis](https://github.com/darkroomengineering/lenis) across the docs, landing page, and demos. Wheel input is interpolated, so scroll-driven components like [Stacked Sections](/docs/scroll/stacked-sections) and [Aperture Reveal](/docs/scroll/aperture-reveal) move continuously instead of in wheel-notch steps. Touch and keyboard keep native scrolling, inner scroll areas keep their own scrolling, and reduced motion turns smoothing off.

Added house motion tokens. `ease-out`, `ease-in`, and `ease-in-out` now use stronger curves with longer settles, and there are new `ease-glide`, `ease-snap`, and `ease-overshoot` curves. Plain `transition` classes default to the house `ease-out` at 200ms. `lib/motion.ts` exposes the same curves for Motion.

Fixed easing classes that were silently ignored. The site defined its custom curves under Tailwind v3's `--transition-timing-function-*` names, which Tailwind v4 doesn't read, so `ease-slow` fell back to the default curve in previews for [Swap Text](/docs/text/swap-text), [Mirror Text](/docs/text/mirror-text), [Tilted Cover](/docs/image/tilted-cover), and [Disclose Image](/docs/image/disclose-image). Installed copies were unaffected; their docs already used the correct names.

4 changes: 2 additions & 2 deletions content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ New components, bug fixes, and infrastructure changes, going back to the beta la

### [October 2026](/docs/changelog/2026-10)

[Aperture Reveal](/docs/scroll/aperture-reveal), a scroll-driven section where a photograph opens out of the headline, and a two-chapter [dawn climb demo](/demo/scroll/aperture-reveal-demo-001).
[Aperture Reveal](/docs/scroll/aperture-reveal), a scroll-driven section where a photograph opens out of the headline, a two-chapter [dawn climb demo](/demo/scroll/aperture-reveal-demo-001), smooth wheel scrolling across the site with Lenis, house motion tokens for easing, and a fix for easing classes Tailwind v4 was ignoring.

### [September 2026](/docs/changelog/2026-09)

Expand All @@ -28,7 +28,7 @@ Homepage conversion fixes for GitHub stars and newsletter subscribe — equal he

| Month | Highlights |
|---|---|
| [October 2026](/docs/changelog/2026-10) | Aperture Reveal, dawn climb demo |
| [October 2026](/docs/changelog/2026-10) | Aperture Reveal, dawn climb demo, Lenis smooth scrolling, motion tokens |
| [September 2026](/docs/changelog/2026-09) | Homepage star + newsletter conversion fixes, PostHog events |
| [June 2026](/docs/changelog/2026-06) | Stacked Sections, polished Wave 1 widgets, Card Spread throw motion, trimmed Card Stack API, buttons guide, react-doctor remediation, unpublished four oversized widgets |
| [May 2026](/docs/changelog/2026-05) | Tailwind 4.3, Boids Ecosystem, Sibling Focus Nav, live demos, docs typography |
Expand Down
28 changes: 28 additions & 0 deletions content/docs/scroll/aperture-reveal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,34 @@ Colors are CSS variables on the root: `--aperture-ground`, `--aperture-ink`, `--

The headline uses `--aperture-font`, which falls back to `--font-display`.

### Smooth scrolling

Scroll-driven sections feel best with smooth wheel scrolling. A mouse wheel moves the page in steps of about 100px, so anything tied to scroll position also moves in steps. [Lenis](https://github.com/darkroomengineering/lenis) interpolates between them. This site uses it:

```tsx
"use client";

import Lenis from "lenis";
import { cancelFrame, frame } from "motion/react";
import { useEffect } from "react";
import "lenis/dist/lenis.css";

export function SmoothScroll() {
useEffect(() => {
const lenis = new Lenis({ lerp: 0.14, allowNestedScroll: true });
const update = ({ timestamp }: { timestamp: number }) => lenis.raf(timestamp);
frame.update(update, true); // one clock for scroll and Motion
return () => {
cancelFrame(update);
lenis.destroy();
};
}, []);
return null;
}
```

Render it once in your root layout. Touch and keyboard keep native scrolling, and Lenis turns smoothing off for `prefers-reduced-motion`.

## How it works

One photograph sits behind the headline, clipped to the opening's rectangle. Scroll progress releases the clip until the photograph fills the stage.
Expand Down
28 changes: 28 additions & 0 deletions content/docs/scroll/stacked-sections.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,34 @@ mkdir -p components/animata/scroll && touch components/animata/scroll/stacked-se
</StackedSections>
```

### Smooth scrolling

Scroll-driven sections feel best with smooth wheel scrolling. A mouse wheel moves the page in steps of about 100px, so anything tied to scroll position also moves in steps. [Lenis](https://github.com/darkroomengineering/lenis) interpolates between them. This site uses it:

```tsx
"use client";

import Lenis from "lenis";
import { cancelFrame, frame } from "motion/react";
import { useEffect } from "react";
import "lenis/dist/lenis.css";

export function SmoothScroll() {
useEffect(() => {
const lenis = new Lenis({ lerp: 0.14, allowNestedScroll: true });
const update = ({ timestamp }: { timestamp: number }) => lenis.raf(timestamp);
frame.update(update, true); // one clock for scroll and Motion
return () => {
cancelFrame(update);
lenis.destroy();
};
}, []);
return null;
}
```

Render it once in your root layout. Touch and keyboard keep native scrolling, and Lenis turns smoothing off for `prefers-reduced-motion`.

## How it works

This mirrors the [CSS stacking-cards demo](https://scroll-driven-animations.style/demos/stacking-cards/css/):
Expand Down
41 changes: 41 additions & 0 deletions lib/motion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Motion tokens for JavaScript animation (Motion, WAAPI). Mirrors the --ease-* tokens in
* styles/globals.css; keep both in sync.
*/

export type CubicBezier = readonly [number, number, number, number];

export const ease = {
/** UI responses: hover, press, menus, toasts. */
out: [0.22, 1, 0.36, 1],
/** Exits only, kept short. */
in: [0.64, 0, 0.78, 0],
/** On-screen moves between two states. */
inOut: [0.4, 0, 0, 1],
/** Large, expressive moves: page transitions, hero reveals. */
glide: [0.65, 0, 0.05, 1],
/** Micro feedback that should feel instant. */
snap: [0.16, 1, 0.3, 1],
/** Playful arrivals with a small overshoot. */
overshoot: [0.34, 1.56, 0.64, 1],
} as const satisfies Record<string, CubicBezier>;

/** Seconds, for Motion. Multiply by 1000 for CSS or WAAPI. */
export const duration = {
instant: 0.12,
fast: 0.2,
base: 0.32,
slow: 0.6,
epic: 1.2,
} as const;

/** Delay between items in a list or a split headline. */
export const stagger = {
tight: 0.03,
base: 0.05,
loose: 0.08,
} as const;

export function toCssBezier([x1, y1, x2, y2]: CubicBezier) {
return `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})`;
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lenis": "^1.3.26",
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
Expand Down
23 changes: 22 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 25 additions & 4 deletions styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,30 @@
transparent 100%
);

--transition-timing-function-slow: cubic-bezier(0.405, 0, 0.025, 1);
--transition-timing-function-minor-spring: cubic-bezier(0.76, 0.34, 0.38, 1.64);
--transition-timing-function-apple: cubic-bezier(0.4, 0, 0.6, 1);
/*
* Motion tokens. Tailwind v4 reads easing classes from --ease-*, so these power
* ease-out, ease-in-out, ease-glide, and the rest. Keep in sync with lib/motion.ts.
*
* ease-out UI responses: hover, press, menus, toasts. Fast start, long settle.
* ease-in Exits only, kept short.
* ease-in-out On-screen moves between two states: tabs, drawers, layout shifts.
* ease-glide Large, expressive moves: page transitions, hero reveals (600ms+).
* ease-snap Micro feedback that should feel instant (100–200ms).
* ease-overshoot Playful arrivals with a small overshoot.
*/
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in: cubic-bezier(0.64, 0, 0.78, 0);
--ease-in-out: cubic-bezier(0.4, 0, 0, 1);
--ease-glide: cubic-bezier(0.65, 0, 0.05, 1);
--ease-snap: cubic-bezier(0.16, 1, 0.3, 1);
--ease-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-slow: cubic-bezier(0.405, 0, 0.025, 1);
--ease-minor-spring: cubic-bezier(0.18, 0.89, 0.82, 1.04);
--ease-apple: cubic-bezier(0.4, 0, 0.6, 1);

/* `transition` with no ease or duration class uses these. */
--default-transition-timing-function: var(--ease-out);
--default-transition-duration: 200ms;

--radius-card: 18px;
--radius-section: 28px;
Expand Down Expand Up @@ -397,7 +418,7 @@
}
}

--animate-section-reveal: section-reveal 0.6s var(--transition-timing-function-apple) both;
--animate-section-reveal: section-reveal 0.6s var(--ease-apple) both;

@keyframes section-reveal {
from {
Expand Down
Loading