diff --git a/apps/videos/src/Root.tsx b/apps/videos/src/Root.tsx
index f4854712..e42c0888 100644
--- a/apps/videos/src/Root.tsx
+++ b/apps/videos/src/Root.tsx
@@ -1,6 +1,60 @@
import "./style.css";
-import { Composition } from "remotion";
+import type React from "react";
+import { AbsoluteFill, Composition } from "remotion";
+import { ClipReveal } from "./components/ClipReveal";
+import { Counter } from "./components/Counter";
+import { GradientBlob } from "./components/GradientBlob";
+import { GrainOverlay } from "./components/GrainOverlay";
+import { PhotoPlaceholder } from "./components/PhotoPlaceholder";
+import { ToolPill } from "./components/ToolPill";
+import { TEXT } from "./lib/fonts";
-export const RemotionRoot: React.FC = () => {
- return <>{/* Compositions will be added as they are built */}>;
-};
+const ComponentTest: React.FC = () => (
+
+
+
+ Hello SnapOtter
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const RemotionRoot: React.FC = () => (
+ <>
+
+ >
+);
diff --git a/apps/videos/src/components/ClipReveal.tsx b/apps/videos/src/components/ClipReveal.tsx
new file mode 100644
index 00000000..0e9400e1
--- /dev/null
+++ b/apps/videos/src/components/ClipReveal.tsx
@@ -0,0 +1,36 @@
+import type React from "react";
+import { interpolate, useCurrentFrame } from "remotion";
+import { EASE } from "@/lib/motion";
+
+export const ClipReveal: React.FC<{
+ children: React.ReactNode;
+ startFrame: number;
+ duration?: number;
+ direction?: "up" | "down";
+}> = ({ children, startFrame, duration = 15, direction = "up" }) => {
+ const frame = useCurrentFrame();
+ const progress = interpolate(frame, [startFrame, startFrame + duration], [0, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ easing: EASE.enter,
+ });
+ const translateY =
+ direction === "up"
+ ? interpolate(progress, [0, 1], [40, 0])
+ : interpolate(progress, [0, 1], [-40, 0]);
+
+ return (
+
+ );
+};
diff --git a/apps/videos/src/components/Counter.tsx b/apps/videos/src/components/Counter.tsx
new file mode 100644
index 00000000..432817a4
--- /dev/null
+++ b/apps/videos/src/components/Counter.tsx
@@ -0,0 +1,21 @@
+import type React from "react";
+import { interpolate, useCurrentFrame } from "remotion";
+import { EASE } from "@/lib/motion";
+
+export const Counter: React.FC<{
+ from: number;
+ to: number;
+ startFrame: number;
+ duration: number;
+ style?: React.CSSProperties;
+ format?: (n: number) => string;
+}> = ({ from, to, startFrame, duration, style, format }) => {
+ const frame = useCurrentFrame();
+ const raw = interpolate(frame, [startFrame, startFrame + duration], [from, to], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ easing: EASE.enter,
+ });
+ const value = Math.floor(raw);
+ return {format ? format(value) : String(value)};
+};
diff --git a/apps/videos/src/components/GradientBlob.tsx b/apps/videos/src/components/GradientBlob.tsx
new file mode 100644
index 00000000..d7ae3da5
--- /dev/null
+++ b/apps/videos/src/components/GradientBlob.tsx
@@ -0,0 +1,41 @@
+import type React from "react";
+import { useCurrentFrame } from "remotion";
+
+interface BlobConfig {
+ color: string;
+ radius: number;
+ cx: number;
+ cy: number;
+ a: number;
+ b: number;
+ phaseX: number;
+ phaseY: number;
+ amplitudeX: number;
+ amplitudeY: number;
+}
+
+export const GradientBlob: React.FC<{
+ config: BlobConfig;
+ duration: number;
+}> = ({ config, duration }) => {
+ const frame = useCurrentFrame();
+ const t = (frame / duration) * Math.PI * 2;
+ const x = config.cx + config.amplitudeX * Math.sin(config.a * t + config.phaseX);
+ const y = config.cy + config.amplitudeY * Math.sin(config.b * t + config.phaseY);
+
+ return (
+
+ );
+};
diff --git a/apps/videos/src/components/GrainOverlay.tsx b/apps/videos/src/components/GrainOverlay.tsx
new file mode 100644
index 00000000..0b4f5289
--- /dev/null
+++ b/apps/videos/src/components/GrainOverlay.tsx
@@ -0,0 +1,23 @@
+import type React from "react";
+import { AbsoluteFill, useCurrentFrame } from "remotion";
+
+export const GrainOverlay: React.FC<{ opacity?: number }> = ({ opacity = 0.03 }) => {
+ const frame = useCurrentFrame();
+ return (
+
+
+
+ );
+};
diff --git a/apps/videos/src/components/PhotoPlaceholder.tsx b/apps/videos/src/components/PhotoPlaceholder.tsx
new file mode 100644
index 00000000..8d5f2592
--- /dev/null
+++ b/apps/videos/src/components/PhotoPlaceholder.tsx
@@ -0,0 +1,20 @@
+import type React from "react";
+
+export const PhotoPlaceholder: React.FC<{
+ width: number;
+ height: number;
+ hue?: number;
+ style?: React.CSSProperties;
+}> = ({ width, height, hue = 30, style }) => (
+
+);
diff --git a/apps/videos/src/components/ToolPill.tsx b/apps/videos/src/components/ToolPill.tsx
new file mode 100644
index 00000000..b8815e88
--- /dev/null
+++ b/apps/videos/src/components/ToolPill.tsx
@@ -0,0 +1,28 @@
+import type React from "react";
+import { COLOR } from "@/lib/colors";
+import { TEXT } from "@/lib/fonts";
+
+export const ToolPill: React.FC<{
+ name: string;
+ category: string;
+ style?: React.CSSProperties;
+}> = ({ name, category, style }) => {
+ const color = COLOR.category[category] ?? COLOR.accent;
+ return (
+
+ {name}
+
+ );
+};
diff --git a/apps/videos/src/components/TypeWriter.tsx b/apps/videos/src/components/TypeWriter.tsx
new file mode 100644
index 00000000..6f48582c
--- /dev/null
+++ b/apps/videos/src/components/TypeWriter.tsx
@@ -0,0 +1,55 @@
+import type React from "react";
+import { useCurrentFrame } from "remotion";
+import { FONT } from "@/lib/fonts";
+
+interface Segment {
+ text: string;
+ color: string;
+}
+
+export const TypeWriter: React.FC<{
+ segments: Segment[];
+ startFrame: number;
+ speed?: number;
+ style?: React.CSSProperties;
+}> = ({ segments, startFrame, speed = 2, style }) => {
+ const frame = useCurrentFrame();
+ const elapsed = Math.max(0, frame - startFrame);
+ const charIndex = Math.floor(elapsed / speed);
+ const fullText = segments.map((s) => s.text).join("");
+ const visibleChars = Math.min(charIndex, fullText.length);
+
+ let rendered = 0;
+ const nodes: React.ReactNode[] = [];
+ for (const seg of segments) {
+ const segStart = rendered;
+ const visible = Math.max(0, Math.min(visibleChars - segStart, seg.text.length));
+ if (visible > 0) {
+ nodes.push(
+
+ {seg.text.slice(0, visible)}
+ ,
+ );
+ }
+ rendered += seg.text.length;
+ if (rendered >= visibleChars) break;
+ }
+
+ const showCursor = visibleChars < fullText.length && Math.floor(frame / 8) % 2 === 0;
+
+ return (
+
+ {nodes}
+ {showCursor && (
+
+ )}
+
+ );
+};
diff --git a/apps/videos/src/components/WipeTransition.tsx b/apps/videos/src/components/WipeTransition.tsx
new file mode 100644
index 00000000..0fd0ff7b
--- /dev/null
+++ b/apps/videos/src/components/WipeTransition.tsx
@@ -0,0 +1,21 @@
+import type React from "react";
+import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
+import { EASE } from "@/lib/motion";
+
+export const WipeTransition: React.FC<{
+ children: React.ReactNode;
+ startFrame: number;
+ duration?: number;
+ direction?: "left" | "right";
+}> = ({ children, startFrame, duration = 18, direction = "left" }) => {
+ const frame = useCurrentFrame();
+ const progress = interpolate(frame, [startFrame, startFrame + duration], [0, 100], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ easing: EASE.enter,
+ });
+ const clipPath =
+ direction === "left" ? `inset(0 ${100 - progress}% 0 0)` : `inset(0 0 0 ${100 - progress}%)`;
+
+ return {children};
+};