diff --git a/marketing/product-film/src/components/Cursor.tsx b/marketing/product-film/src/components/Cursor.tsx
new file mode 100644
index 0000000..4a4f042
--- /dev/null
+++ b/marketing/product-film/src/components/Cursor.tsx
@@ -0,0 +1,57 @@
+import { useCurrentFrame, useVideoConfig, spring, interpolate, AbsoluteFill } from "remotion";
+import { theme } from "../theme";
+
+type Pt = { x: number; y: number }; // pixels in the 1920x1080 canvas
+
+export const Cursor: React.FC<{
+ from: Pt;
+ to: Pt;
+ moveStartFrame: number;
+ moveDurationFrames?: number;
+ clickAtFrame?: number;
+}> = ({ from, to, moveStartFrame, moveDurationFrames = 22, clickAtFrame }) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+
+ const p = spring({
+ frame: frame - moveStartFrame,
+ fps,
+ durationInFrames: moveDurationFrames,
+ config: { damping: 200 },
+ });
+ const x = interpolate(p, [0, 1], [from.x, to.x]);
+ const y = interpolate(p, [0, 1], [from.y, to.y]);
+
+ let pulse = 0;
+ if (clickAtFrame !== undefined) {
+ pulse = interpolate(frame - clickAtFrame, [0, 8], [1, 0], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+ }
+
+ return (
+
+
+ {pulse > 0 && (
+
+ )}
+
+
+
+ );
+};
diff --git a/marketing/product-film/src/components/TypeReveal.tsx b/marketing/product-film/src/components/TypeReveal.tsx
new file mode 100644
index 0000000..dd3b989
--- /dev/null
+++ b/marketing/product-film/src/components/TypeReveal.tsx
@@ -0,0 +1,20 @@
+import { useCurrentFrame } from "remotion";
+import { theme } from "../theme";
+
+export const TypeReveal: React.FC<{
+ text: string;
+ startFrame: number;
+ framesPerChar?: number;
+ style?: React.CSSProperties;
+}> = ({ text, startFrame, framesPerChar = 1.6, style }) => {
+ const frame = useCurrentFrame();
+ const shown = Math.max(0, Math.floor((frame - startFrame) / framesPerChar));
+ const visible = text.slice(0, shown);
+ const blinkOn = Math.floor(frame / 15) % 2 === 0;
+ return (
+
+ {visible}
+ {shown < text.length && blinkOn ? | : null}
+
+ );
+};