Skip to content

Commit f625bc0

Browse files
committed
Add animation to video captions
1 parent 2f55e58 commit f625bc0

File tree

5 files changed

+147
-52
lines changed

5 files changed

+147
-52
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-scrolly-telling",
3-
"version": "0.1.5",
3+
"version": "0.1.6",
44
"description": "Create scrolly-telling animations in React with ease.",
55
"type": "module",
66
"keywords": [

src/components/VideoCaptions.tsx

Lines changed: 123 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useState } from "react";
2-
import { type Styles } from "../utils/react-helpers.js";
2+
33
import { roundToDecimal } from "../utils/math.js";
4+
import interpolate from "../interpolate.js";
45

56
export interface VideoCaptionsProps {
67
videoRef: React.RefObject<HTMLVideoElement>;
@@ -15,28 +16,35 @@ export interface ScrollyVideoCaptionsConfig {
1516
horizontalPadding?: string | number;
1617
/** Styles applied to all caption containers */
1718
style?: React.CSSProperties;
19+
20+
animation?: ScrollyVideoCaptionAnimation;
1821
}
1922

2023
export default function VideoCaptions(
2124
props: VideoCaptionsProps
2225
): JSX.Element | null {
2326
const { captions = [], videoRef, config = {} } = props;
24-
const { horizontalPadding, verticalPadding, style } = config;
27+
const { horizontalPadding, verticalPadding, style, animation } = config;
2528
const currentTime = useVideoCurrentTime(videoRef);
2629

2730
return (
28-
<>
29-
{captions?.map((caption, i) => (
31+
<ul
32+
style={{ listStyle: "none", padding: "none" }}
33+
className="scrolly-video-captions"
34+
data-current-time={currentTime}
35+
>
36+
{captions?.map((caption) => (
3037
<VideoCaption
3138
horizontalPadding={horizontalPadding}
3239
verticalPadding={verticalPadding}
40+
animation={animation}
3341
{...caption}
3442
commonStyle={style}
3543
currentTime={currentTime}
36-
key={i}
44+
key={`${caption.fromTimestamp}-${caption.toTimestamp}-${caption.position}`}
3745
/>
3846
))}
39-
</>
47+
</ul>
4048
);
4149
}
4250

@@ -76,6 +84,13 @@ export type ScrollyVideoCaptionPosition = `${
7684
| CaptionPosition.Center
7785
| CaptionPosition.Right}`;
7886

87+
export interface ScrollyVideoCaptionAnimation {
88+
durationInSeconds: number;
89+
variant: ScrollyVideoCaptionAnimationVariant;
90+
}
91+
92+
export type ScrollyVideoCaptionAnimationVariant = "fade";
93+
7994
export interface ScrollyVideoCaptionProps {
8095
/** Content of the caption. Can be string or a component. */
8196
children: React.ReactNode;
@@ -91,38 +106,95 @@ export interface ScrollyVideoCaptionProps {
91106
verticalPadding?: string | number;
92107
/** @default 5vw */
93108
horizontalPadding?: string | number;
109+
110+
animation?: ScrollyVideoCaptionAnimation;
94111
}
95112

96113
interface VideoCaptionProps extends ScrollyVideoCaptionProps {
97114
currentTime: number;
98115
commonStyle?: React.CSSProperties;
99116
}
100117

101-
const styles = {
102-
caption: {
103-
boxSizing: "border-box",
104-
position: "absolute",
105-
zIndex: 0,
106-
},
107-
} satisfies Styles;
108-
109-
const DEFAULT_POSITION: ScrollyVideoCaptionPosition = `${CaptionPosition.Center}-${CaptionPosition.Center}`;
110-
const DEFAULT_VERTICAL_PADDING = "5vh";
111-
const DEFAULT_HORIZONTAL_PADDING = "5vw";
118+
const DEFAULT_ANIMATION_DURATION = 0.5;
119+
const DEFAULT_ANIMATION_VARIANT: ScrollyVideoCaptionAnimationVariant = "fade";
112120

113121
function VideoCaption(props: VideoCaptionProps): JSX.Element | null {
114122
const {
115123
children,
116124
currentTime = 0,
117125
fromTimestamp,
118126
toTimestamp,
127+
commonStyle,
128+
style,
129+
animation,
119130
...options
120131
} = props;
121-
const isVisible = currentTime >= fromTimestamp && currentTime <= toTimestamp;
132+
const animationDuration =
133+
animation?.durationInSeconds ?? DEFAULT_ANIMATION_DURATION;
134+
135+
// Overlap animation by half of the duration
136+
const isVisible =
137+
currentTime >= fromTimestamp - animationDuration / 2 &&
138+
currentTime <= toTimestamp + animationDuration / 2;
122139

123140
if (!isVisible) return null;
124141

125-
return <div style={calculateVideoCaptionStyle(options)}>{children}</div>;
142+
return (
143+
<li
144+
className="scrolly-video-caption"
145+
data-timestamp-from={fromTimestamp}
146+
data-timestamp-to={toTimestamp}
147+
style={{
148+
...commonStyle,
149+
...style,
150+
...calculateVideoCaptionStyle(options),
151+
...calculateVideoCaptionAnimation(animation, {
152+
currentTime,
153+
fromTimestamp,
154+
toTimestamp,
155+
}),
156+
}}
157+
>
158+
{children}
159+
</li>
160+
);
161+
}
162+
163+
function calculateVideoCaptionAnimation(
164+
animation: ScrollyVideoCaptionAnimation | undefined,
165+
options: {
166+
currentTime: number;
167+
fromTimestamp: number;
168+
toTimestamp: number;
169+
}
170+
): React.CSSProperties | undefined {
171+
const { currentTime, fromTimestamp, toTimestamp } = options;
172+
173+
const animationVariant = animation?.variant ?? DEFAULT_ANIMATION_VARIANT;
174+
const animationDuration =
175+
animation?.durationInSeconds ?? DEFAULT_ANIMATION_DURATION;
176+
177+
const fromTimeWithAnimation = fromTimestamp - animationDuration / 2;
178+
const toTimeWithAnimation = toTimestamp + animationDuration / 2;
179+
180+
const entryRatio = interpolate(currentTime, {
181+
sourceFrom: fromTimeWithAnimation,
182+
sourceTo: fromTimeWithAnimation + animationDuration,
183+
targetFrom: 0,
184+
targetTo: 1,
185+
precision: 2,
186+
});
187+
const exitRatio = interpolate(currentTime, {
188+
sourceFrom: toTimeWithAnimation - animationDuration,
189+
sourceTo: toTimeWithAnimation,
190+
targetFrom: 1,
191+
targetTo: 0,
192+
precision: 2,
193+
});
194+
195+
return animationVariant === "fade"
196+
? { opacity: entryRatio === 1 ? exitRatio : entryRatio }
197+
: undefined;
126198
}
127199

128200
interface CalculateVideoCaptionStyleOptions {
@@ -133,45 +205,51 @@ interface CalculateVideoCaptionStyleOptions {
133205
horizontalPadding?: string | number;
134206
}
135207

208+
const DEFAULT_POSITION: ScrollyVideoCaptionPosition = `${CaptionPosition.Center}-${CaptionPosition.Center}`;
209+
const DEFAULT_VERTICAL_PADDING = "5vh";
210+
const DEFAULT_HORIZONTAL_PADDING = "5vw";
211+
136212
function calculateVideoCaptionStyle({
137-
commonStyle,
138-
style,
139213
position = DEFAULT_POSITION,
140214
verticalPadding = DEFAULT_VERTICAL_PADDING,
141215
horizontalPadding = DEFAULT_HORIZONTAL_PADDING,
142216
}: CalculateVideoCaptionStyleOptions): React.CSSProperties {
143217
const [verticalPosition, horizontalPosition] = position.split("-");
144218

145-
const verticalStyle: React.CSSProperties =
219+
const alignItems: React.CSSProperties["alignItems"] =
146220
verticalPosition === CaptionPosition.Top
147-
? { top: verticalPadding, verticalAlign: verticalPosition }
221+
? "flex-start"
148222
: verticalPosition === CaptionPosition.Bottom
149-
? { bottom: verticalPadding, verticalAlign: verticalPosition }
150-
: { top: "50%", verticalAlign: "middle" };
223+
? "flex-end"
224+
: "center";
225+
226+
const justifyContent: React.CSSProperties["justifyContent"] =
227+
horizontalPosition === CaptionPosition.Left
228+
? "flex-start"
229+
: horizontalPosition === CaptionPosition.Right
230+
? "flex-end"
231+
: "center";
151232

152-
const horizontalStyle: React.CSSProperties =
233+
const textAlign: React.CSSProperties["textAlign"] =
153234
horizontalPosition === CaptionPosition.Left
154-
? { left: horizontalPadding, textAlign: horizontalPosition }
235+
? "left"
155236
: horizontalPosition === CaptionPosition.Right
156-
? { right: horizontalPadding, textAlign: horizontalPosition }
157-
: { left: "50%", textAlign: "center" };
158-
159-
const transform: string | undefined =
160-
verticalPosition === CaptionPosition.Center &&
161-
horizontalPosition === CaptionPosition.Center
162-
? "translate(-50%,-50%)"
163-
: verticalPosition === CaptionPosition.Center
164-
? "translateY(-50%)"
165-
: horizontalPosition === CaptionPosition.Center
166-
? "translateX(-50%)"
167-
: undefined;
237+
? "right"
238+
: "center";
168239

169240
return {
170-
...commonStyle,
171-
...style,
172-
...styles.caption,
173-
...verticalStyle,
174-
...horizontalStyle,
175-
transform,
241+
boxSizing: "border-box",
242+
position: "absolute",
243+
display: "flex",
244+
flexDirection: "row",
245+
top: verticalPadding,
246+
bottom: verticalPadding,
247+
left: horizontalPadding,
248+
right: horizontalPadding,
249+
insetBlock: verticalPadding,
250+
insetInline: horizontalPadding,
251+
alignItems,
252+
justifyContent,
253+
textAlign,
176254
};
177255
}

src/stories/ScrollyVideo.stories.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,18 @@ const positions: ScrollyVideoCaptionProps["position"][] = [
5151

5252
export const Captions: StoryObj<ComponentType> = {
5353
args: {
54-
// playFromEntry: true,
55-
// playTillExit: true,
54+
style: { height: "300vh" },
55+
playFromEntry: false,
56+
playTillExit: false,
5657
captions: positions.map((position, i) => ({
5758
position,
58-
children: <>Long Caption text at position: {position}</>,
59-
fromTimestamp: i + 0,
60-
toTimestamp: i + 5,
59+
children: (
60+
<div style={{ maxWidth: "25vw", textShadow: "0 0 4px #0008" }}>
61+
Long Caption text at position: {position}
62+
</div>
63+
),
64+
fromTimestamp: i + 0.5,
65+
toTimestamp: i + 4.5,
6166
})),
6267
captionsConfig: {
6368
verticalPadding: "40px",
@@ -66,7 +71,6 @@ export const Captions: StoryObj<ComponentType> = {
6671
fontWeight: "bold",
6772
fontSize: "2em",
6873
color: "white",
69-
width: "300px",
7074
},
7175
},
7276
},

src/video.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useRef } from "react";
2+
23
import VideoElement, {
34
type VideoTimeChangeFn,
45
} from "./components/VideoElement.js";

vite.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { defineConfig } from "vite";
33
import dtsPlugin from "vite-plugin-dts";
44
import { peerDependencies, devDependencies } from "./package.json";
55

6+
const facadeModuleIdMap = new Map<string, boolean>();
7+
68
export default defineConfig({
79
build: {
810
lib: {
@@ -12,6 +14,7 @@ export default defineConfig({
1214
interpolate: "src/interpolate.ts",
1315
scrim: "src/scrim.ts",
1416
video: "src/video.tsx",
17+
videoCaption: "src/components/VideoCaptions.tsx",
1518
},
1619
name: "react-scrolly-telling",
1720
formats: ["es", "cjs"],
@@ -37,6 +40,15 @@ export default defineConfig({
3740
},
3841

3942
chunkFileNames: (info) => {
43+
if (info.facadeModuleId) {
44+
if (facadeModuleIdMap.has(info.facadeModuleId)) {
45+
return `__[name]__.cjs`;
46+
} else {
47+
facadeModuleIdMap.set(info.facadeModuleId, true);
48+
return `__[name]__.mjs`;
49+
}
50+
}
51+
4052
const cjs = info.exports.some((e) => e.length > 5);
4153
return `__[name]__.${cjs ? "cjs" : "mjs"}`;
4254
},

0 commit comments

Comments
 (0)