A Scroll Trigger coordinates the revealing components inside it: Text Motion, Image, Image Group and nested triggers. As it nears the viewport it asks every member to prepare, so images start loading. Once it is in view and everything is ready, it reveals the members in document order, stagger milliseconds apart. Here the heading waits for its fonts and the caption waits for all three images. The trigger itself is an unstyled block, so lay out its content as you like.
A group or trigger inside the trigger is a single member: it is ready when all of its own members are, and it plays its own sequence after the delay it receives. Members never reveal by themselves inside a trigger. A member added after the sequence reveals as soon as it is ready. It is a block before it is defined too, so binding it never moves the content around it.
The trigger prepares members within 25% of the visible area (and of scroll containers), and reveals on any intersection. Without IntersectionObserver, or under reduced motion, the sequence plays as soon as the members are ready, without stagger.
Stagger order
Members without their own reveal use the trigger’s. stagger-from starts the sequence from the first, last or center member.
From the center
Default density
Compact density
Restart from the initial state
Show the copyable source
reveal-repeat resets every member once the trigger is fully out of view and plays the sequence again on the way back. Use it sparingly, for sections people return to. With reveal-repeat it reveals only once it is 10% inside the viewport and resets the moment it is entirely out, so a member’s start offset cannot pull it back in and loop.
Replay on entry
Default density
Compact density
Restart from the initial state
Show the copyable source
Scroll down, then back up.
Every release, reviewed by a human.
Two approvals before anything reaches production.
Scroll down, then back up.
Every release, reviewed by a human.
Two approvals before anything reaches production.
<div style="inline-size:100%;block-size:14rem;overflow:auto;border:var(--tp-border-width) solid var(--tp-border);border-radius:var(--tp-radius-lg);padding-inline:var(--tp-space-6)">
<p style="margin:0;padding-block:var(--tp-space-6);block-size:16rem;box-sizing:border-box;color:var(--tp-muted-foreground)">Scroll down, then back up.</p>
<tp-scroll-trigger reveal-repeat stagger="150" reveal="fade up">
<p style="margin:0;font-size:var(--tp-text-xl);font-weight:var(--tp-font-semibold)"><tp-mo-text-split split="words">Every release, reviewed by a human.</tp-mo-text-split></p>
<p style="margin:var(--tp-space-2) 0 0;color:var(--tp-muted-foreground)"><tp-mo-text-split>Two approvals before anything reaches production.</tp-mo-text-split></p>
</tp-scroll-trigger>
<div style="block-size:16rem"></div>
</div>
Scrubbing
scrub maps the scroll position onto the same choreography a timed reveal would play, in both directions. scrub-range picks the stretch of scroll that drives it (contain, cover, entry, exit, or offsets like entry 25% contain 40%), and scrub-smoothing lets progress trail the scroll slightly. reveal-repeat has no effect while scrubbing.
Scrubbed band
Default density
Compact density
Restart from the initial state
Show the copyable source
The first line slides in with the scroll,
then the second,
and back out as you scroll up.
The first line slides in with the scroll,
then the second,
and back out as you scroll up.
<div style="inline-size:100%;block-size:20rem;overflow:auto;border:var(--tp-border-width) solid var(--tp-border);border-radius:var(--tp-radius-lg);padding-inline:var(--tp-space-6)">
<div style="display:grid;gap:var(--tp-space-3);align-content:end;block-size:20rem;padding-block:var(--tp-space-6);box-sizing:border-box">
<tp-skeleton motion="none" style="block-size:0.75rem;inline-size:45%"></tp-skeleton>
<tp-skeleton motion="none" style="block-size:0.75rem;inline-size:90%"></tp-skeleton>
<tp-skeleton motion="none" style="block-size:6rem"></tp-skeleton>
</div>
<tp-scroll-trigger scrub scrub-range="cover" scrub-smoothing="0.8" stagger="150" reveal="fade left">
<p style="margin:0;font-size:var(--tp-text-xl)"><tp-mo-text-split>The first line slides in with the scroll,</tp-mo-text-split></p>
<p style="margin:0;font-size:var(--tp-text-xl)"><tp-mo-text-split>then the second,</tp-mo-text-split></p>
<p style="margin:0;font-size:var(--tp-text-xl)"><tp-mo-text-split>and back out as you scroll up.</tp-mo-text-split></p>
</tp-scroll-trigger>
<div style="display:grid;gap:var(--tp-space-3);block-size:20rem;padding-block:var(--tp-space-6);box-sizing:border-box">
<tp-skeleton motion="none" style="block-size:0.75rem;inline-size:80%"></tp-skeleton>
<tp-skeleton motion="none" style="block-size:6rem"></tp-skeleton>
<tp-skeleton motion="none" style="block-size:0.75rem;inline-size:60%"></tp-skeleton>
</div>
</div>
Offsets work like CSS animation-range. With scrub-range="entry 25% contain 40%" the reveal starts once a quarter of the section has entered and is finished well before it reaches the middle of the frame, so it sits still while people read it. Add scrub-once to leave revealed content at rest when scrolling back. A start without a percentage means 0% of its range and an end without one 100% (entry exit runs from starting to enter to finishing leaving); a single point such as cover 50% runs from there to the end of its range. A value that cannot be parsed falls back to contain.
Members are still asked to prepare when the trigger nears the viewport, but scrubbing does not wait for them: an image that is still loading scrubs in with its placeholder. Under reduced motion every member is at rest and progress is still reported. Turning scrub off returns the members to timed reveals.
Custom scroll range
Default density
Compact density
Restart from the initial state
Show the copyable source
pin holds the content in a sticky stage while the scroll advances through an extra length, --tp-scroll-trigger-pin-length. With scrub, that is the classic pinned scene. Give the scroll container container-type: size so the stage fills it exactly; without one, the stage is the viewport.
Pinned scene
Default density
Compact density
Restart from the initial state
Show the copyable source
Scroll inside this frame.
Every metric, one calm dashboard
Revenue, retention and usage stay pinned while you read.
Scroll back up to play it in reverse.
Scroll inside this frame.
Every metric, one calm dashboard
Revenue, retention and usage stay pinned while you read.
Scroll back up to play it in reverse.
<div style="inline-size:100%;block-size:24rem;overflow:auto;container-type:size;border:var(--tp-border-width) solid var(--tp-border);border-radius:var(--tp-radius-lg);padding-inline:var(--tp-space-6)">
<div style="display:grid;gap:var(--tp-space-3);align-content:start;min-block-size:100cqb;padding-block:var(--tp-space-6);box-sizing:border-box">
<tp-skeleton motion="none" style="block-size:0.75rem;inline-size:40%"></tp-skeleton>
<tp-skeleton motion="none" style="block-size:0.75rem;inline-size:90%"></tp-skeleton>
<tp-skeleton motion="none" style="block-size:6rem"></tp-skeleton>
<p style="margin:0;color:var(--tp-muted-foreground);font-size:var(--tp-text-sm)">Scroll inside this frame.</p>
</div>
<tp-scroll-trigger scrub pin stagger="250" style="--tp-scroll-trigger-pin-length:150cqb">
<div style="block-size:100%;display:grid;align-content:center;gap:var(--tp-space-4)">
<h3 style="margin:0;font-size:var(--tp-text-2xl);line-height:var(--tp-leading-tight)">
<tp-mo-text-split split="words" mask="words" reveal="up" stagger="80">Every metric, one calm dashboard</tp-mo-text-split>
</h3>
<p style="margin:0;color:var(--tp-muted-foreground)"><tp-mo-text-split split="words" reveal="fade" stagger="40">Revenue, retention and usage stay pinned while you read.</tp-mo-text-split></p>
<p style="margin:0;color:var(--tp-muted-foreground)"><tp-mo-text-split split="words" reveal="fade" stagger="40">Scroll back up to play it in reverse.</tp-mo-text-split></p>
</div>
</tp-scroll-trigger>
<div style="display:grid;gap:var(--tp-space-3);min-block-size:100cqb;padding-block:var(--tp-space-6);box-sizing:border-box">
<tp-skeleton motion="none" style="block-size:0.75rem;inline-size:85%"></tp-skeleton>
<tp-skeleton motion="none" style="block-size:6rem"></tp-skeleton>
<tp-skeleton motion="none" style="block-size:0.75rem;inline-size:60%"></tp-skeleton>
</div>
</div>
The stage is one visible extent tall (minus --tp-scroll-trigger-pin-top, for fixed headers) and clips what does not fit, so lay the scene out inside it as above. The track has the same height before the element is defined (from @tweakpad/ui/styles.css), so pinning never moves the content that follows. Pinning also works without scrub, for a timed reveal inside a pinned stage. As for any sticky element, there must be no clipping ancestor between the trigger and its scroller.
Events
tp-reveal-change fires when the sequence starts and tp-reveal-change-complete when the last member settles. tp-scroll-progress reports scrubbed progress each frame, for driving your own animation. Members’ events bubble under the same names, so check event.target. To sync other motion, set reveal-hold, wait for what you need, then clear it.
Reveal events
Default density
Compact density
Restart from the initial state
Show the copyable source
Welcome to your workspace
Your projects, people and files in one place.
Welcome to your workspace
Your projects, people and files in one place.
<div style="display:grid;gap:var(--tp-space-3);inline-size:100%;text-align:center">
<tp-scroll-trigger id="scroll-trigger-events" stagger="200" reveal="fade up">
<p style="margin:0;font-size:var(--tp-text-xl);font-weight:var(--tp-font-semibold)"><tp-mo-text-split split="words">Welcome to your workspace</tp-mo-text-split></p>
<p style="margin:var(--tp-space-1) 0 0;color:var(--tp-muted-foreground)"><tp-mo-text-split>Your projects, people and files in one place.</tp-mo-text-split></p>
</tp-scroll-trigger>
<output id="scroll-trigger-events-output" style="color:var(--tp-muted-foreground);font-size:var(--tp-text-sm);font-family:var(--tp-font-mono)">Waiting</output>
</div>
<script type="module">
const trigger = document.querySelector('#scroll-trigger-events');
const output = document.querySelector('#scroll-trigger-events-output');
trigger.addEventListener('tp-reveal-change', (event) => {
if (event.target === trigger && event.detail.revealed) output.textContent = 'Revealing…';
});
trigger.addEventListener('tp-reveal-change-complete', (event) => {
if (event.target === trigger && event.detail.revealed) output.textContent = 'Revealed';
});
</script>
API Reference
Every element also accepts motion-policy.
Attributes
AttributePropertyTypeDefaultDescriptionstaggerstaggernumber0Milliseconds between consecutive member reveals; 0 reveals them together.stagger-fromstaggerFromStaggerFrom'first'The member the stagger starts from.revealrevealstring''Default reveal effect for members without their own.reveal-repeatrevealRepeatbooleanfalseReplay the sequence each time the trigger re-enters view.reveal-holdrevealHoldbooleanfalseWhile set (or while any member holds), every member waits in its start state.scrubscrubbooleanfalseReveal with the scroll position instead of over time.scrub-rangescrubRangestring'contain'The scroll range mapped to progress while scrubbing: contain, cover, entry or exit,
or, as in CSS animation-range, a start and an end point such as entry 20% contain 50%.scrub-smoothingscrubSmoothingnumber0How far scrubbed progress trails the scroll, 0 through 0.98; 0 is locked to it.scrub-oncescrubOncebooleanfalseScrubbed progress only moves forward, leaving revealed members at rest on scroll back.pinpinbooleanfalsePin the content in a sticky stage for an extra scroll length.
Properties
PropertyTypeDefaultDescriptionstatusread-onlyScrollTriggerStatus—Aggregate readiness of the members.membersread-onlyHTMLElement[]—The member elements, in document order.revealedread-onlyboolean—Whether the sequence has played (false again after a repeat reset).progressread-onlynumber—Scrubbed progress, 0 through 1 (0 when not scrubbing).
Events
EventTypeDescriptiontp-scroll-progressCustomEvent<{ progress: number; }>{ progress } in each frame scrubbed progress changes.tp-loading-status-changeCustomEvent<{ status: "idle" | "loading" | "ready"; ready: number; failed: number; total: number; }>{ status, ready, failed, total } when the aggregate readiness changes.tp-reveal-changeCustomEvent<{ revealed: boolean; }>{ revealed } when the sequence starts or a repeat resets it.tp-reveal-change-completeCustomEvent<{ revealed: boolean; }>{ revealed } after the last member's reveal settles.
Slots
SlotDescriptiondefaultContent, including the members at any depth.
CSS parts
PartDescription::part(stage)The wrapper of the content, which sticks in place while pinned.
CSS custom properties
PropertyDefaultDescription--tp-scroll-trigger-pin-length—Extra scroll length while pinned. Default 200cqb (twice the visible extent: the nearest size container, otherwise the viewport).--tp-scroll-trigger-pin-top—Pinned offset from the viewport start. Default 0.