Skip to content

Commit 8f46aa4

Browse files
Merge pull request #50 from basementstudio/stagger-component
Stagger component
2 parents dfdb2cf + 1216706 commit 8f46aa4

File tree

8 files changed

+299
-65
lines changed

8 files changed

+299
-65
lines changed

.changeset/slow-knives-hunt.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"website": patch
3+
"@bsmnt/scrollytelling": patch
4+
---
5+
6+
Stagger component
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
/* -------------------------------------------------------------------------------------------------
2+
* Stagger
3+
* -----------------------------------------------------------------------------------------------*/
4+
5+
import * as React from "react";
6+
import {
7+
StaggerBaseDef,
8+
TweenWithChildrenDef,
9+
TweenWithTargetDef,
10+
} from "../../types";
11+
import { Animation } from "../animation";
12+
import { isDev } from "../../util";
13+
14+
export function Stagger(
15+
props: StaggerBaseDef & {
16+
tween: TweenWithTargetDef;
17+
}
18+
): React.ReactElement;
19+
20+
export function Stagger(
21+
props: StaggerBaseDef & {
22+
children?: React.ReactNode[];
23+
tween: TweenWithChildrenDef;
24+
}
25+
): React.ReactElement;
26+
27+
export function Stagger({
28+
children,
29+
overlap,
30+
tween,
31+
disabled = false,
32+
}: StaggerBaseDef & {
33+
children?: React.ReactNode[];
34+
tween: TweenWithChildrenDef | TweenWithTargetDef;
35+
}) {
36+
const isTweenWithTarget = "target" in tween;
37+
const targetLength =
38+
isTweenWithTarget && Array.isArray(tween.target)
39+
? tween.target.length
40+
: children?.length;
41+
42+
const timeline = React.useMemo(() => {
43+
if (tween?.start === undefined || tween?.end === undefined) {
44+
if (isDev) {
45+
throw new Error("Stagger needs a start and end value");
46+
} else {
47+
console.warn("Stagger needs a start and end value");
48+
}
49+
50+
return [];
51+
}
52+
53+
return getStaggeredTimeline({
54+
start: tween?.start,
55+
end: tween?.end,
56+
chunks: targetLength,
57+
overlap: overlap,
58+
});
59+
}, [targetLength, overlap, tween?.end, tween?.start]);
60+
61+
if (children) {
62+
return children.map((child, i) => {
63+
const currTween = timeline[i];
64+
65+
if (!currTween) {
66+
return null;
67+
}
68+
69+
return (
70+
<Animation
71+
key={i}
72+
tween={{
73+
...tween,
74+
start: currTween.start,
75+
end: currTween.end,
76+
}}
77+
disabled={disabled}
78+
>
79+
{child}
80+
</Animation>
81+
);
82+
});
83+
} else if (isTweenWithTarget) {
84+
const target = tween.target;
85+
86+
if (Array.isArray(target)) {
87+
return target.map((target, i) => {
88+
const currTween = timeline[i];
89+
90+
if (!currTween) {
91+
return null;
92+
}
93+
94+
if (tween.to) {
95+
return (
96+
<Animation
97+
key={i}
98+
tween={{
99+
...tween,
100+
target: target,
101+
start: currTween.start,
102+
end: currTween.end,
103+
to: {
104+
...tween.to,
105+
onUpdateParams: [i],
106+
},
107+
}}
108+
disabled={disabled}
109+
/>
110+
);
111+
} else if (tween.from) {
112+
return (
113+
<Animation
114+
key={i}
115+
tween={{
116+
...tween,
117+
target: target,
118+
start: currTween.start,
119+
end: currTween.end,
120+
from: { ...tween.from, onUpdateParams: [i] },
121+
}}
122+
disabled={disabled}
123+
/>
124+
);
125+
} else if (tween.fromTo) {
126+
return (
127+
<Animation
128+
key={i}
129+
tween={{
130+
...tween,
131+
target: target,
132+
start: currTween.start,
133+
end: currTween.end,
134+
fromTo: [
135+
{
136+
...tween.fromTo[0],
137+
},
138+
{
139+
...tween.fromTo[1],
140+
onUpdateParams: [i],
141+
},
142+
],
143+
}}
144+
disabled={disabled}
145+
/>
146+
);
147+
}
148+
});
149+
} else if (isDev) {
150+
throw new Error("Stagger target must be an array");
151+
}
152+
}
153+
154+
return <></>;
155+
}
156+
157+
// Overlap duration arrays by a factor mantaining the final duration between them
158+
const overlapDurationArrayByFactor = (
159+
durations: { start: number; end: number }[],
160+
factor: number
161+
) => {
162+
const first = durations[0];
163+
const last = durations[durations.length - 1];
164+
165+
if (first === undefined || last === undefined) {
166+
throw Error("Durations array is empty");
167+
}
168+
169+
/*
170+
We need the veryStart because is our left shift in a [0% - 100%] timeline. Overlapping durations
171+
affect the timeline total duration & we don't want that, so to scale it proportionally. Steps are:
172+
1. We unshift it to bring the start to 0
173+
2. Then we scale it proportionally to match the initial totalDuration
174+
3. And then we shift it back to the veryStart
175+
*/
176+
const veryStart = first.start;
177+
const veryEnd = last.end;
178+
const totalDuration = veryEnd - veryStart;
179+
180+
const overlapDuration = totalDuration * factor;
181+
const overlapDurationPerDuration = overlapDuration / durations.length;
182+
183+
const afterOverlapDuration =
184+
totalDuration - overlapDurationPerDuration * (durations.length - 1);
185+
const afterOverlapDurationDiffFactor = totalDuration / afterOverlapDuration;
186+
187+
const newDurations = durations.map((duration, i) => {
188+
const newStart = duration.start - overlapDurationPerDuration * i;
189+
const newEnd = duration.end - overlapDurationPerDuration * i;
190+
191+
return {
192+
start: Math.max(
193+
veryStart + (newStart - veryStart) * afterOverlapDurationDiffFactor,
194+
0
195+
),
196+
end: Math.min(
197+
veryStart + (newEnd - veryStart) * afterOverlapDurationDiffFactor,
198+
100
199+
),
200+
};
201+
});
202+
203+
return newDurations;
204+
};
205+
206+
export const getStaggeredTimeline = (config: {
207+
start: number;
208+
end: number;
209+
overlap?: number;
210+
chunks?: number;
211+
}) => {
212+
const { start, end, overlap = 0, chunks = 1 } = config;
213+
214+
if (overlap > 1 || overlap < 0) {
215+
throw new Error("Overlap must be between 0 and 1");
216+
}
217+
218+
const duration = end - start;
219+
const chunkDuration = duration / chunks;
220+
221+
const animationChunks = Array.from({ length: chunks }).map((_, i) => {
222+
const chunkStart = start + chunkDuration * i;
223+
const chunkEnd = chunkStart + chunkDuration;
224+
225+
return {
226+
start: chunkStart,
227+
end: chunkEnd,
228+
};
229+
});
230+
231+
if (overlap > 0) {
232+
return overlapDurationArrayByFactor(animationChunks, overlap);
233+
}
234+
235+
return animationChunks;
236+
};

scrollytelling/src/primitive.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Parallax } from "./components/parallax";
1111
import { Pin } from "./components/pin";
1212
import { RegisterGsapPlugins } from "./components/register-plugins";
1313
import { Waypoint } from "./components/waypoint";
14+
import { Stagger } from './components/stagger'
1415

1516
// ---- Context ----
1617
import {
@@ -214,6 +215,7 @@ export {
214215
Pin,
215216
RegisterGsapPlugins,
216217
Waypoint,
218+
Stagger,
217219
//
218220
useScrollytelling,
219221
useScrollToLabel,

scrollytelling/src/types/index.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { gsap } from "gsap";
33
// ---- Utils
44
export type DataOrDataArray<T> = T | Array<T>;
55
export type UnitValue<Unit = string> = { value: number; unit: Unit };
6-
export type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
7-
Pick<T, Exclude<keyof T, Keys>>
8-
& {
9-
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>
10-
}[Keys]
6+
export type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<
7+
T,
8+
Exclude<keyof T, Keys>
9+
> &
10+
{
11+
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
12+
}[Keys];
1113

1214
// ---- Tween Specifics
1315
export type FromToOptions =
@@ -46,10 +48,18 @@ export type WaypointBaseDef = {
4648
disabled?: boolean;
4749
};
4850

51+
export type StaggerBaseDef = {
52+
overlap: number;
53+
disabled?: boolean;
54+
};
55+
4956
// FIXME: This name is not clear, why SimpleTween doesn't consume TweenBaseDef?
50-
export type SimpleTween = FromToOptions & { duration: number; forwards?: boolean };
57+
export type SimpleTween = FromToOptions & {
58+
duration: number;
59+
forwards?: boolean;
60+
};
5161

5262
// ---- Aliases
53-
export type TweenVars = gsap.TweenVars;
63+
export type TweenVars = gsap.TweenVars;
5464

5565
export type Plugin = Parameters<typeof gsap.registerPlugin>[number];

scrollytelling/src/util/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,5 @@ export function getValidAt(at: number) {
103103
}
104104
return at;
105105
}
106+
107+
export const isDev = process.env.NODE_ENV === "development";

turbo.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"$schema": "https://turborepo.org/schema.json",
3+
"globalEnv": ["NODE_ENV"],
34
"pipeline": {
45
"build": {
56
"dependsOn": ["^build"],

website/src/app/sections/falling-caps/caps.tsx

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,6 @@ export const CapsModel = () => {
9393
const halfViewportWidth = responsiveVPWidth / 2;
9494
const fadeInYoffset = 0.1;
9595

96-
const capsTimeline = useMemo(() => {
97-
return getTimeline({
98-
start: 48,
99-
end: 100,
100-
chunks: clonedMaterials.length,
101-
overlap: 0.65,
102-
});
103-
}, [clonedMaterials]);
10496

10597
const handleUpdate = React.useCallback(
10698
(idx: number) => {
@@ -132,29 +124,24 @@ export const CapsModel = () => {
132124

133125
return (
134126
<>
135-
{capProps.map((p, idx) => {
136-
const currCapAnimation = capsTimeline[idx];
137-
138-
if (!currCapAnimation) return;
139-
140-
return (
141-
<Scrollytelling.Animation
142-
key={idx}
143-
tween={{
144-
start: currCapAnimation.start,
145-
end: currCapAnimation.end,
146-
target: [capProps[idx]],
147-
to: {
148-
progress: 1,
149-
ease: "power2.inOut",
150-
onUpdate: () => {
151-
handleUpdate(idx);
152-
},
127+
<Scrollytelling.Stagger
128+
overlap={0.65}
129+
tween={
130+
{
131+
start: 48,
132+
end: 100,
133+
target: capProps,
134+
to: {
135+
progress: 1,
136+
ease: "power2.inOut",
137+
/* We pass current target idx by params */
138+
onUpdate: (idx) => {
139+
handleUpdate(idx);
153140
},
154-
}}
155-
/>
156-
);
157-
})}
141+
},
142+
}
143+
}
144+
/>
158145

159146
{/* clean this up when the scrollytelling leaves, as when scrolling really fast,
160147
GSAP is not being able to do so */}

0 commit comments

Comments
 (0)