Aperture Reveal
A photograph opens out of a pill-shaped gap in the headline. The words part, the image fills the stage, and the caption is read in word by word as you scroll.
See it with page scroll in the dawn climb demo.
Installation
CLI
pnpm dlx shadcn@latest add https://animata.design/r/scroll/aperture-reveal.json
Manual
Install dependencies
npm install motionAdd the reduced-motion hook
Skip this step if you already have hooks/use-prefers-reduced-motion.ts from another component.
"use client";
import { useSyncExternalStore } from "react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
if (typeof mq.addEventListener === "function") {
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
const legacyMq = mq as MediaQueryList & {
addListener: (listener: () => void) => void;
removeListener: (listener: () => void) => void;
};
legacyMq.addListener(callback);
return () => legacyMq.removeListener(callback);
}
function getReducedMotionSnapshot() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function usePrefersReducedMotion() {
return useSyncExternalStore(subscribeReducedMotion, getReducedMotionSnapshot, () => false);
}Create the files
mkdir -p components/animata/scroll && touch components/animata/scroll/aperture-reveal.tsx components/animata/scroll/aperture-reveal.cssPaste the code
/*
* Layout, color, spacing, and type live in Tailwind classes on each part.
* This file holds what Tailwind can't express cleanly: values computed every frame from
* two scroll variables, the hint's pseudo-element animation, and a height container query.
*
* --e 0 → 1 the opening widens until the photograph fills the stage
* --q 0 → 1 the caption is read in, word by word
*/
@layer components {
.aperture__stage {
--e: 0;
--q: 0;
/* The opening's rectangle, measured in JS. 50% hides the photograph until measured. */
--aperture-top: 50%;
--aperture-right: 50%;
--aperture-bottom: 50%;
--aperture-left: 50%;
--aperture-radius: 0px;
}
.aperture__media {
clip-path: inset(
calc(var(--aperture-top) * (1 - var(--e))) calc(var(--aperture-right) * (1 - var(--e)))
calc(var(--aperture-bottom) * (1 - var(--e))) calc(var(--aperture-left) * (1 - var(--e)))
round calc(var(--aperture-radius) * (1 - var(--e)))
);
will-change: clip-path;
}
/* Whatever the consumer passes (img, video, Next Image) covers the stage. */
.aperture__media > :not(.aperture__wash) {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(calc(1.32 - 0.32 * var(--e)));
will-change: transform;
}
.aperture__wash {
opacity: var(--q);
}
.aperture__lead,
.aperture__trail {
opacity: calc(1 - var(--e) * 1.6);
will-change: transform, opacity;
}
.aperture__lead {
transform: translateX(calc(var(--e) * -42cqw));
}
.aperture__trail {
transform: translateX(calc(var(--e) * 42cqw));
}
/* Narrow stages stack the headline, so the words part vertically. */
@container stage (width < 640px) {
.aperture__lead {
transform: translateY(calc(var(--e) * -44cqh));
}
.aperture__trail {
transform: translateY(calc(var(--e) * 44cqh));
}
}
.aperture__caption {
opacity: clamp(0, var(--q) * 12, 1);
}
/* Each word fades over a window of four words: --i is its index, --n the word count. */
.aperture__word {
--w: clamp(0, (var(--q) * (var(--n) + 4) - var(--i)) / 4, 1);
opacity: calc(0.16 + 0.84 * var(--w));
}
.aperture__action {
opacity: clamp(0, (var(--q) - 0.8) * 5, 1);
transform: translateY(calc((1 - clamp(0, (var(--q) - 0.8) * 5, 1)) * 12px));
}
.aperture__action:focus-within {
opacity: 1;
transform: none;
}
.aperture__hint {
opacity: calc(0.6 - var(--e) * 4);
}
.aperture__hint::after {
content: "";
width: 1px;
height: 28px;
background: currentColor;
animation: aperture-hint 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
/* Before hydration the static layout isn't applied yet; never loop for reduced motion. */
@media (prefers-reduced-motion: reduce) {
.aperture__hint::after {
animation: none;
}
}
@keyframes aperture-hint {
0% {
transform: scaleY(0);
transform-origin: top;
}
50% {
transform: scaleY(1);
transform-origin: top;
}
51% {
transform-origin: bottom;
}
100% {
transform: scaleY(0);
transform-origin: bottom;
}
}
/* Reduced motion: release every scroll-driven value. Layout comes from Tailwind. */
.aperture[data-static] .aperture__media {
clip-path: none;
}
.aperture[data-static] .aperture__media > :not(.aperture__wash),
.aperture[data-static] :is(.aperture__lead, .aperture__trail, .aperture__action) {
transform: none;
}
.aperture[data-static]
:is(
.aperture__wash,
.aperture__lead,
.aperture__trail,
.aperture__caption,
.aperture__word,
.aperture__action
) {
opacity: 1;
}
}
/*
* Short, wide stages (a phone in landscape). Tailwind has no container height variant, and
* these must beat the Tailwind sizes on the same parts, so they stay outside the layer.
*/
@container stage (height < 480px) and (width >= 640px) {
.aperture__headline {
font-size: clamp(1.75rem, 11cqh, 4rem);
}
.aperture__caption {
padding-top: 16px;
}
.aperture__text {
font-size: clamp(1rem, 4.5cqh, 1.5rem);
}
.aperture__hint {
display: none;
}
}"use client";
import {
type MotionStyle,
type MotionValue,
motion,
useMotionValueEvent,
useScroll,
useTransform,
} from "motion/react";
import {
type ComponentProps,
type CSSProperties,
createContext,
type FocusEvent,
type ReactNode,
type Ref,
type RefObject,
use,
useId,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
import { cn } from "@/lib/utils";
import "./aperture-reveal.css";
const EXPAND_END = 0.55;
const CAPTION_START = 0.6;
const CAPTION_END = 0.92;
const ACTION_READY = 0.85;
const WHITESPACE = /\s+/;
const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
/** Lets a consumer's ref and the component's own ref point at the same element. */
function mergeRefs<T>(...refs: (Ref<T> | undefined)[]) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) (ref as RefObject<T | null>).current = node;
}
};
}
const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
type ApertureContextValue = {
/** Reduced motion: every part renders in reading order with no scroll sequence. */
isStatic: boolean;
headlineId: string;
read: MotionValue<number>;
trackRef: RefObject<HTMLDivElement | null>;
openingRef: RefObject<HTMLSpanElement | null>;
};
const ApertureContext = createContext<ApertureContextValue | null>(null);
function useAperture(part: string) {
const context = use(ApertureContext);
if (!context) {
throw new Error(`ApertureReveal.${part} must be used within <ApertureReveal>.`);
}
return context;
}
export type ApertureRevealProps = Omit<ComponentProps<"section">, "children"> & {
children: ReactNode;
/** Scroll distance for the whole sequence, in stage heights. Minimum 1.5. */
length?: number;
/**
* Render as a self-contained scroll box of this height in pixels instead of
* following page scroll. Use for previews and embedded panels.
*/
viewportHeight?: number;
};
function ApertureRevealRoot({
children,
length = 3,
viewportHeight,
className,
style,
ref,
...props
}: ApertureRevealProps) {
const isStatic = usePrefersReducedMotion();
const headlineId = useId();
const rootRef = useRef<HTMLElement>(null);
const setRootRef = useMemo(() => mergeRefs(rootRef, ref), [ref]);
const trackRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const openingRef = useRef<HTMLSpanElement>(null);
const contained = viewportHeight !== undefined && !isStatic;
const { scrollYProgress } = useScroll({
target: trackRef,
container: contained ? rootRef : undefined,
offset: ["start start", "end end"],
});
// One progress value drives every layer through CSS variables; scrolling never re-renders.
const expand = useTransform(scrollYProgress, (p) => easeInOutCubic(clamp01(p / EXPAND_END)));
const read = useTransform(scrollYProgress, (p) =>
clamp01((p - CAPTION_START) / (CAPTION_END - CAPTION_START)),
);
// The photograph is clipped to the opening's rectangle, then released to the full stage.
useLayoutEffect(() => {
const stage = stageRef.current;
const opening = openingRef.current;
if (isStatic || !stage || !opening) return;
let active = true;
const measure = () => {
if (!active) return;
const s = stage.getBoundingClientRect();
const w = opening.getBoundingClientRect();
// Rects include ancestor transforms (scaled previews); clip-path uses local pixels.
const scale = stage.offsetWidth ? s.width / stage.offsetWidth : 1;
const local = (value: number) => `${value / scale}px`;
stage.style.setProperty("--aperture-top", local(w.top - s.top));
stage.style.setProperty("--aperture-right", local(s.right - w.right));
stage.style.setProperty("--aperture-bottom", local(s.bottom - w.bottom));
stage.style.setProperty("--aperture-left", local(w.left - s.left));
stage.style.setProperty("--aperture-radius", local(w.height / 2));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(stage);
// The opening moves when the words beside it rewrap, without changing its own size.
for (const sibling of opening.parentElement?.children ?? []) observer.observe(sibling);
document.fonts?.ready.then(measure);
return () => {
active = false;
observer.disconnect();
};
}, [isStatic]);
const context = useMemo(
() => ({ isStatic, headlineId, read, trackRef, openingRef }),
[isStatic, headlineId, read],
);
const rootStyle = {
...style,
"--aperture-length": Math.max(1.5, length),
...(contained ? { "--aperture-viewport": `${viewportHeight}px` } : null),
} as CSSProperties;
return (
<ApertureContext value={context}>
<section
ref={setRootRef}
aria-labelledby={headlineId}
{...props}
className={cn(
// `aperture` scopes the scroll-driven CSS; tokens are overridable with style or class.
"aperture group/aperture relative w-full bg-(--aperture-ground) font-(family-name:--aperture-font) text-(--aperture-ink)",
"[--aperture-accent-ink:#141413] [--aperture-accent:#ffcc00] [--aperture-ground:#ebe9e4] [--aperture-ink:#141413] [--aperture-opening:#d6d3cc] [--aperture-viewport:100svh]",
"[--aperture-font:var(--font-display,var(--font-sans,ui-sans-serif,system-ui,sans-serif))]",
"dark:[--aperture-ground:#111112] dark:[--aperture-ink:#ebe9e4] dark:[--aperture-opening:#26262a]",
"data-contained:h-(--aperture-viewport) data-contained:overflow-y-auto data-contained:[scrollbar-width:none] data-contained:[&::-webkit-scrollbar]:hidden",
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--aperture-ink)",
className,
)}
data-static={isStatic ? "" : undefined}
data-contained={contained ? "" : undefined}
// A scroll box must be reachable by keyboard to be scrollable without a pointer.
tabIndex={contained ? 0 : props.tabIndex}
style={rootStyle}
>
<div
ref={trackRef}
className="relative h-[calc(var(--aperture-length)*var(--aperture-viewport))] group-data-static/aperture:h-auto"
>
<motion.div
ref={stageRef}
className={cn(
"aperture__stage sticky top-0 h-(--aperture-viewport) overflow-hidden [container:stage/size]",
"group-data-static/aperture:relative group-data-static/aperture:grid group-data-static/aperture:h-auto group-data-static/aperture:[container:stage/inline-size] group-data-static/aperture:[grid-template-areas:'headline'_'media']",
)}
style={isStatic ? undefined : ({ "--e": expand, "--q": read } as MotionStyle)}
>
{children}
</motion.div>
</div>
</section>
</ApertureContext>
);
}
/** The photograph or video. Its child should fill the box: `img`, `video`, or Next `Image fill`. */
function ApertureMedia({ className, children, ...props }: ComponentProps<"div">) {
useAperture("Media");
return (
<div
className={cn(
"aperture__media absolute inset-0 z-1 overflow-hidden bg-(--aperture-opening)",
"group-data-static/aperture:relative group-data-static/aperture:aspect-[16/10] group-data-static/aperture:max-h-svh group-data-static/aperture:min-h-[420px] group-data-static/aperture:[grid-area:media]",
className,
)}
{...props}
>
{children}
<div
className="aperture__wash absolute inset-0 bg-linear-to-t from-[rgb(10_10_11/0.82)] via-[rgb(10_10_11/0.35)] via-55% to-[rgb(10_10_11/0.1)]"
aria-hidden="true"
/>
</div>
);
}
type ApertureHeadlineProps = Omit<ComponentProps<"h2">, "id"> & {
as?: "h1" | "h2" | "h3";
};
function ApertureHeadline({ as: Heading = "h2", className, ...props }: ApertureHeadlineProps) {
const { headlineId } = useAperture("Headline");
// The id is set last: the section's aria-labelledby depends on it.
return (
<Heading
{...props}
className={cn(
"aperture__headline pointer-events-none absolute inset-0 z-2 m-0 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-x-[0.22em] px-[clamp(16px,4cqw,56px)] font-semibold text-[clamp(2.25rem,6.4cqw,6.25rem)] leading-[0.95] tracking-[-0.04em] text-balance wrap-break-word",
"@max-[640px]/stage:grid-cols-1 @max-[640px]/stage:grid-rows-[auto_auto_auto] @max-[640px]/stage:content-center @max-[640px]/stage:justify-items-center @max-[640px]/stage:gap-y-[0.14em] @max-[640px]/stage:text-center @max-[640px]/stage:text-[clamp(2.25rem,12cqw,4.5rem)]",
"group-data-static/aperture:static group-data-static/aperture:flex group-data-static/aperture:flex-wrap group-data-static/aperture:justify-center group-data-static/aperture:gap-x-[0.22em] group-data-static/aperture:gap-y-[0.08em] group-data-static/aperture:py-[clamp(64px,12cqw,160px)] group-data-static/aperture:text-center group-data-static/aperture:[grid-area:headline]",
className,
)}
id={headlineId}
/>
);
}
/** Words before the opening. They part left, or up on narrow stages. */
function ApertureLead({ className, children, ...props }: ComponentProps<"span">) {
useAperture("Lead");
return (
<span
className={cn(
"aperture__lead min-w-0 justify-self-end text-right @max-[640px]/stage:justify-self-center @max-[640px]/stage:text-center group-data-static/aperture:text-center",
className,
)}
{...props}
>
{children}
{/* Keeps the heading's accessible name "lead trail", not "leadtrail". */}{" "}
</span>
);
}
/** Words after the opening. They part right, or down on narrow stages. */
function ApertureTrail({ className, ...props }: ComponentProps<"span">) {
useAperture("Trail");
return (
<span
className={cn(
"aperture__trail min-w-0 justify-self-start text-left @max-[640px]/stage:justify-self-center @max-[640px]/stage:text-center group-data-static/aperture:text-center",
className,
)}
{...props}
/>
);
}
/**
* The gap in the headline the photograph grows from. Decorative. With reduced motion it is
* hidden unless given children, such as a small copy of the photograph.
*/
function ApertureOpening({ className, children, ref, ...props }: ComponentProps<"span">) {
const { openingRef } = useAperture("Opening");
const setRef = useMemo(() => mergeRefs(openingRef, ref), [openingRef, ref]);
return (
<span
ref={setRef}
aria-hidden="true"
className={cn(
"block h-[0.86em] w-[2.1em] overflow-hidden rounded-full @max-[640px]/stage:h-[1.1em] @max-[640px]/stage:w-[2.6em]",
"group-data-static/aperture:hidden group-data-static/aperture:data-filled:relative group-data-static/aperture:data-filled:block group-data-static/aperture:data-filled:bg-(--aperture-opening) group-data-static/aperture:*:size-full group-data-static/aperture:*:object-cover",
className,
)}
data-filled={children ? "" : undefined}
{...props}
>
{children}
</span>
);
}
/** Overlay for the text and action shown once the photograph fills the stage. */
function ApertureCaption({ className, ...props }: ComponentProps<"div">) {
useAperture("Caption");
return (
<div
className={cn(
"aperture__caption absolute inset-x-0 bottom-0 z-3 flex flex-wrap items-end justify-between gap-x-12 gap-y-6 p-[clamp(20px,5cqw,64px)] text-[#f4f2ed]",
"@max-[640px]/stage:flex-col @max-[640px]/stage:items-start",
"group-data-static/aperture:relative group-data-static/aperture:self-end group-data-static/aperture:[grid-area:media]",
className,
)}
{...props}
/>
);
}
type ApertureTextProps = Omit<ComponentProps<"p">, "children"> & {
/** Plain text, read in word by word. */
children: string;
};
function ApertureText({ children, className, ...props }: ApertureTextProps) {
useAperture("Text");
const words = children.split(WHITESPACE).filter(Boolean);
return (
<p
className={cn(
"aperture__text m-0 max-w-[30em] font-medium text-[clamp(1.125rem,2.7cqw,2.25rem)] leading-[1.2] tracking-[-0.02em] text-pretty",
className,
)}
{...props}
>
<span className="sr-only">{children}</span>
<span aria-hidden="true" style={{ "--n": words.length } as CSSProperties}>
{words.map((word, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: words repeat; position is identity
key={index}
className="aperture__word"
style={{ "--i": index } as CSSProperties}
>
{word}{" "}
</span>
))}
</span>
</p>
);
}
/**
* Wraps a link or button, such as `<a>` or Next `<Link>`. It appears after the text has been
* read. Focusing it early scrolls to the end of the sequence so its context is visible.
*/
function ApertureAction({
className,
onFocus,
ref: forwardedRef,
...props
}: ComponentProps<"div">) {
const { isStatic, read, trackRef } = useAperture("Action");
const ref = useRef<HTMLDivElement>(null);
const setRef = useMemo(() => mergeRefs(ref, forwardedRef), [forwardedRef]);
useMotionValueEvent(read, "change", (value) => {
ref.current?.toggleAttribute("data-ready", value > ACTION_READY);
});
const handleFocus = (event: FocusEvent<HTMLDivElement>) => {
onFocus?.(event);
if (!isStatic && read.get() < ACTION_READY) {
trackRef.current?.scrollIntoView({ block: "end" });
}
};
return (
// Focus bubbles from the link or button inside; the wrapper itself is not interactive.
// biome-ignore lint/a11y/noStaticElementInteractions: delegated focus listener only
<div
{...props}
ref={setRef}
className={cn(
"aperture__action pointer-events-none focus-within:pointer-events-auto data-ready:pointer-events-auto group-data-static/aperture:pointer-events-auto",
// Styles the link or button passed in, whatever component renders it.
"*:inline-flex *:min-h-12 *:cursor-pointer *:items-center *:whitespace-nowrap *:rounded-full *:border-0 *:bg-(--aperture-accent) *:px-6 *:font-semibold *:text-(--aperture-accent-ink) *:text-[15px] *:tracking-[-0.005em] *:no-underline *:transition-colors *:duration-200",
"*:hover:bg-[color-mix(in_srgb,var(--aperture-accent)_86%,white)] *:focus-visible:outline-2 *:focus-visible:outline-[#f4f2ed] *:focus-visible:outline-offset-3",
className,
)}
onFocus={handleFocus}
/>
);
}
/** Small cue at the bottom of the stage. Fades as soon as the opening starts to widen. */
function ApertureHint({ className, ...props }: ComponentProps<"span">) {
useAperture("Hint");
return (
<span
aria-hidden="true"
className={cn(
"aperture__hint pointer-events-none absolute bottom-[clamp(16px,4cqh,32px)] left-1/2 z-2 flex -translate-x-1/2 flex-col items-center gap-2.5 font-mono text-[11px] uppercase tracking-[0.08em] group-data-static/aperture:hidden",
className,
)}
{...props}
/>
);
}
const ApertureReveal = Object.assign(ApertureRevealRoot, {
Media: ApertureMedia,
Headline: ApertureHeadline,
Lead: ApertureLead,
Opening: ApertureOpening,
Trail: ApertureTrail,
Caption: ApertureCaption,
Text: ApertureText,
Action: ApertureAction,
Hint: ApertureHint,
}) as typeof ApertureRevealRoot & {
Media: typeof ApertureMedia;
Headline: typeof ApertureHeadline;
Lead: typeof ApertureLead;
Opening: typeof ApertureOpening;
Trail: typeof ApertureTrail;
Caption: typeof ApertureCaption;
Text: typeof ApertureText;
Action: typeof ApertureAction;
Hint: typeof ApertureHint;
};
export default ApertureReveal;
export {
ApertureAction,
ApertureCaption,
ApertureHeadline,
ApertureHint,
ApertureLead,
ApertureMedia,
ApertureOpening,
ApertureReveal,
ApertureRevealRoot,
ApertureText,
ApertureTrail,
};Usage
import Image from "next/image";
import Link from "next/link";
import ApertureReveal from "@/components/animata/scroll/aperture-reveal";
<ApertureReveal>
<ApertureReveal.Media>
<Image
src="/images/ridge.jpg"
alt="Snow-capped mountains above a forested valley and a braided river"
fill
sizes="100vw"
/>
</ApertureReveal.Media>
<ApertureReveal.Headline>
<ApertureReveal.Lead>Leave early,</ApertureReveal.Lead>
<ApertureReveal.Opening />
<ApertureReveal.Trail>arrive at first light</ApertureReveal.Trail>
</ApertureReveal.Headline>
<ApertureReveal.Caption>
<ApertureReveal.Text>
The ridge trail opens two hours before sunrise. Start with a guide, reach the lookout as
the valley fills with light, and be back down before the heat.
</ApertureReveal.Text>
<ApertureReveal.Action>
<Link href="/climbs">Plan the climb</Link>
</ApertureReveal.Action>
</ApertureReveal.Caption>
<ApertureReveal.Hint>Scroll</ApertureReveal.Hint>
</ApertureReveal>The parts are composed, so you choose what goes in each one. Media takes an img, a Next Image with fill, or a muted video. Action wraps your own link or button, including a router Link. Leave out Action or Hint if you don't need them. Each part accepts the props of its element, such as className and style.
By default the section follows page scroll. It is length stage heights tall, and the stage stays pinned while you scroll through it. Pass viewportHeight to make it a scroll box of its own, for example inside a modal. The preview above uses viewportHeight={640}.
Pinning uses position: sticky. An ancestor with overflow: hidden, overflow: auto, or a transform stops sticky from working. Use overflow: clip on the ancestor instead, or use viewportHeight.
Colors
Colors are CSS variables on the root: --aperture-ground, --aperture-ink, --aperture-opening, --aperture-accent, and --aperture-accent-ink. Dark values apply under a .dark ancestor. Set them with style or a class for a one-off palette:
<ApertureReveal
style={{ "--aperture-ground": "#0d0e12", "--aperture-ink": "#e9e7e1" } as React.CSSProperties}
>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 interpolates between them. This site uses it:
"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.
section.aperture ← tall track: length × stage height
div.aperture__stage ← position: sticky; sets --e (opening) and --q (caption)
Media ← clip-path: inset(opening rect × (1 - --e))
Headline Lead | Opening | Trail
Caption Text, Action ← fades in with --q
HintLayout, color, spacing, and type are Tailwind classes on each part, so a className on any part overrides them. aperture-reveal.css holds only what Tailwind can't express: the scroll-driven calc() values, the hint's animation, and a container height query.
One scroll progress value drives everything through those two CSS variables, so scrolling never re-renders React. The clip, the image scale, the wash, the headline's movement, and each caption word's opacity are all calc() expressions of --e and --q.
- The first 55% of the scroll opens the photograph with an ease-in-out curve. The image starts at 1.32× and settles to 1× as it widens, so it feels like a lens opening rather than a box stretching.
- The caption runs from 60% to 92%. Each word fades from 16% to full opacity, overlapping its neighbors by four words.
- The opening's rectangle is measured from layout. It is measured again when the stage resizes, when the words beside it rewrap, and after web fonts load. Measurements are corrected for scaled ancestors, so the clip also lines up inside scaled previews.
- Stages narrower than 640px stack the headline and move the words vertically. Short, wide stages, such as a phone in landscape, use a smaller headline and hide the hint. Both are container queries, so they respond to the section's size, not the window's.
When to use
- Moving from a headline into a photograph or video without a hard cut, such as a product launch, a travel or property story, or an editorial feature.
- One strong image per section. Two or three sections on a page work well; the demo uses two.
When not to use
- Content people need to reach quickly, like pricing, forms, or navigation. The sequence takes a few screens of scrolling.
- Images where the center crop doesn't work. The opening shows the middle of the photograph first.
- Inside a container you can't give
overflow: clipor a fixed height. Use a plain image instead.
Parts
| Prop | Type | Default | Description |
|---|---|---|---|
ApertureReveal | section | — | Root. |
Media | div | — | Holds the photograph or video. Its child is stretched to cover the stage. Give an image real |
Headline | h2 | — | The headline. Use |
Lead | span | — | Words before the opening. They move left, or up on narrow stages. |
Opening | span | — | The decorative gap the photograph grows from. With reduced motion it is hidden unless you give it children, such as a small copy of the image. |
Trail | span | — | Words after the opening. They move right, or down on narrow stages. |
Caption | div | — | Overlay for the text and action at the end of the sequence. |
Text | p | — | Plain text, read in word by word. |
Action | div | — | Wraps one link or button and styles it. It appears after the text. |
Hint | span | — | Small decorative cue at the bottom of the stage. It fades as soon as the opening starts to widen. |
Accessibility
- The section is labelled by its headline. Lead and trail read as one sentence; the opening is hidden from screen readers.
Textgives screen readers the whole sentence at once. The animated words are hidden from them.Actionstays in the tab order. If it gets focus before it has appeared, the section scrolls to the end so the action is shown with its text.- With
viewportHeight, the section is focusable, so keyboard users can scroll it with the arrow keys and Space. - With
prefers-reduced-motion, the same parts render in reading order: the headline, then the full photograph with its caption and action. Nothing is pinned or scrubbed, and the hint is hidden.
Changelog
- 2026-10-14 — Added. Composable parts, clip-path opening driven by a single scroll value, word-by-word caption with a single accessible sentence, contained keyboard-scrollable mode, container-query layouts for narrow and short stages, and a static reduced-motion version. Added the dawn climb demo.