Cutship
Sign inGet started
← All edits
cutship.ai1 / 9

Carousel · 4:5

Ink Notes Carousel

by CutshipVerified

A quiet, save-worthy list carousel: moody, desaturated street and city photos full-bleed, with one small cream paper note per slide, handwritten in blue ballpoint with words underlined by hand. Taped and torn notes, film grain, and a last slide that points to your full podcast episode.

0 taken · 1 views

Free

Sign up free to Make this

Sign up free to unlock the spec, agent prompt and Make this.

components/remotion/ink-notes-carousel/component.tsx

import React, { useEffect, useState } from "react";
import { AbsoluteFill, Img, continueRender, delayRender, random, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
import { loadFont as loadHand } from "@remotion/google-fonts/NothingYouCouldDo";
import { loadFont as loadUi } from "@remotion/google-fonts/Inter";
import { getAvailableFonts } from "@remotion/google-fonts";
import type { InkNotesCarouselProps } from "./schema";

const { fontFamily: handFont } = loadHand("normal", { weights: ["400"], subsets: ["latin"] });
const { fontFamily: uiFont } = loadUi("normal", { weights: ["400", "600"], subsets: ["latin"] });

/** Loads a brand-kit Google Font by family name; returns it once ready. */
const useBrandFont = (family?: string) => {
  const [ready, setReady] = useState<string | null>(null);
  useEffect(() => {
    if (!family) return setReady(null);
    const entry = getAvailableFonts().find((f) => f.fontFamily.toLowerCase() === family.toLowerCase());
    if (!entry) return;
    const handle = delayRender(`Loading font ${family}`);
    entry
      .load()
      .then((mod) => mod.loadFont("normal", { subsets: ["latin"] }).waitUntilDone())
      .then(() => setReady(entry.fontFamily))
      .catch(() => setReady(null))
      .finally(() => continueRender(handle));
  }, [family]);
  return ready;
};

const resolve = (src: string) => (src.startsWith("http") || src.startsWith("/") ? src : staticFile(src));
const f1 = (n: number) => n.toFixed(1);
const W = 1080;
const H = 1350;

/* ------------------------------------------------------------------------ */
/* Grounds                                                                   */
/* ------------------------------------------------------------------------ */

const MOOD_FILTER = {
  "black and white": "grayscale(1) contrast(1.14) brightness(0.8)",
  faded: "grayscale(0.78) sepia(0.12) saturate(0.7) contrast(0.96) brightness(0.8)",
  original: "brightness(0.92)",
} as const;

/** Full-bleed photo, graded and grained. With no photo: an underexposed, grainy dark ground. */
const Ground: React.FC<{ src?: string; mood: keyof typeof MOOD_FILTER; color: string; seed: number }> = ({ src, mood, color, seed }) => (
  <AbsoluteFill style={{ background: color, overflow: "hidden" }}>
    {src ? (
      <Img src={resolve(src)} style={{ width: "100%", height: "100%", objectFit: "cover", filter: MOOD_FILTER[mood] }} />
    ) : (
      <>
        {/* uneven, underexposed surface: faint mottling plus a fine concrete tooth */}
        <svg width={W} height={H} style={{ position: "absolute", inset: 0 }}>
          <filter id={`ink-blotch-${seed}`}>
            <feTurbulence type="fractalNoise" baseFrequency="0.006 0.009" numOctaves="4" seed={seed} />
            <feColorMatrix values="0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 0.075 -0.01" />
          </filter>
          <filter id={`ink-tooth-${seed}`}>
            <feTurbulence type="fractalNoise" baseFrequency="0.11" numOctaves="3" seed={seed + 3} />
            <feColorMatrix values="0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 0.15 -0.06" />
          </filter>
          <rect width={W} height={H} filter={`url(#ink-blotch-${seed})`} />
          <rect width={W} height={H} filter={`url(#ink-tooth-${seed})`} />
        </svg>
        {/* one low lamp off to the side */}
        <AbsoluteFill style={{ background: `linear-gradient(${seed % 2 ? 155 : 205}deg, rgba(255,250,240,0.07), rgba(0,0,0,0) 45%, rgba(0,0,0,0.3))` }} />
      </>
    )}
    {/* edge falloff, like a lens wide open in low light */}
    <AbsoluteFill style={{ boxShadow: "inset 0 0 220px rgba(0,0,0,0.55)" }} />
    {/* film grain */}
    <svg width={W} height={H} style={{ position: "absolute", inset: 0, mixBlendMode: "overlay", opacity: src ? 0.42 : 0.6 }}>
      <filter id={`ink-grain-${seed}`}>
        <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed={seed + 7} />
        <feColorMatrix type="saturate" values="0" />
      </filter>
      <rect width={W} height={H} filter={`url(#ink-grain-${seed})`} />
    </svg>
  </AbsoluteFill>
);

/* ------------------------------------------------------------------------ */
/* Paper                                                                     */
/* ------------------------------------------------------------------------ */

/** Polygon (in %) for a note whose top edge was torn off a pad. */
const tornTop = (seed: string) => {
  const pts: string[] = [];
  for (let x = 0; x <= 100; x += 1.6 + random(`${seed}-tx${x}`) * 2.2) pts.push(`${f1(Math.min(x, 100))}% ${f1(random(`${seed}-ty${x}`) * 1.6)}%`);
  pts.push("100% 0.8%", "100% 100%", "0% 100%");
  return `polygon(${pts.join(",")})`;
};

const Tape: React.FC<{ color: string; style: React.CSSProperties; w?: number; seed: string }> = ({ color, style, w = 170, seed }) => {
  const edge = (side: "l" | "r") =>
    Array.from({ length: 6 }, (_, k) => {
      const y = (k / 5) * 100;
      const off = random(`${seed}-${side}${k}`) * 4;
      return side === "l" ? `${f1(off)}% ${y}%` : `${f1(100 - off)}% ${100 - y}%`;
    });
  return (
    <div
      style={{
        position: "absolute",
        width: w,
        height: 46,
        background: color,
        opacity: 0.82,
        clipPath: `polygon(${[...edge("l"), ...edge("r")].join(",")})`,
        boxShadow: "inset 0 0 10px rgba(110,95,60,0.25)",
        ...style,
      }}
    />
  );
};

type NoteVariant = "tape" | "corners" | "torn" | "plain";

/** A small cream paper note with a soft contact shadow. Position is its center. */
const Note: React.FC<{
  x: number;
  y: number;
  w: number;
  rot: number;
  seed: string;
  paper: string;
  tape: string;
  variant: NoteVariant;
  pad?: string;
  children: React.ReactNode;
}> = ({ x, y, w, rot, seed, paper, tape, variant, pad = "46px 48px 50px", children }) => (
  <div style={{ position: "absolute", left: x, top: y, width: w, transform: `translate(-50%, -50%) rotate(${rot}deg)` }}>
    <div style={{ filter: "drop-shadow(0 2px 1.5px rgba(0,0,0,0.38)) drop-shadow(0 16px 22px rgba(0,0,0,0.42))" }}>
      <div
        style={{
          position: "relative",
          background: paper,
          padding: variant === "torn" ? pad.replace(/^\d+px/, (m) => `${parseInt(m) + 14}px`) : pad,
          clipPath: variant === "torn" ? tornTop(seed) : undefined,
          overflow: "hidden",
        }}
      >
        {/* paper fibre + a faint fold of light across the sheet */}
        <svg width="100%" height="100%" style={{ position: "absolute", inset: 0, mixBlendMode: "multiply", opacity: 0.75 }}>
          <filter id={`ink-fibre-${seed}`}>
            <feTurbulence type="fractalNoise" baseFrequency="0.9 0.35" numOctaves="3" seed={Math.floor(random(seed) * 99)} />
            <feColorMatrix values="0 0 0 0 0.55  0 0 0 0 0.5  0 0 0 0 0.42  0 0 0 -1.1 0.95" />
          </filter>
          <rect width="100%" height="100%" filter={`url(#ink-fibre-${seed})`} />
        </svg>
        <div
          style={{
            position: "absolute",
            inset: 0,
            background: `linear-gradient(${f1(100 + random(`${seed}-g`) * 40)}deg, rgba(0,0,0,0.05), rgba(255,255,255,0.08) 45%, rgba(0,0,0,0.07))`,
            boxShadow: "inset 0 0 26px rgba(120,100,60,0.14)",
          }}
        />
        <div style={{ position: "relative" }}>{children}</div>
      </div>
    </div>
    {variant === "tape" ? <Tape color={tape} seed={`${seed}-t`} style={{ left: "50%", top: -24, transform: `translateX(-50%) rotate(${f1((random(`${seed}-tr`) - 0.5) * 8)}deg)` }} /> : null}
    {variant === "corners" ? (
      <>
        <Tape color={tape} seed={`${seed}-c1`} w={130} style={{ left: -44, top: -6, transform: "rotate(-38deg)" }} />
        <Tape color={tape} seed={`${seed}-c2`} w={130} style={{ right: -44, bottom: -4, transform: "rotate(-38deg)" }} />
      </>
    ) : null}
  </div>
);

/* ------------------------------------------------------------------------ */
/* Ink                                                                       */
/* ------------------------------------------------------------------------ */

type Token = { word: string; under: boolean; joinNext: boolean; tail?: string };

/**
 * Splits "a _b c_, d" into words; underlined phrases keep one continuous stroke
 * across their spaces, and punctuation right after an underline sticks to its word.
 */
const tokenize = (text: string): Token[] => {
  const out: Token[] = [];
  text.split(/(_[^_]+_)/g).forEach((part) => {
    if (!part) return;
    const under = part.startsWith("_") && part.endsWith("_") && part.length > 2;
    let body = under ? part.slice(1, -1) : part;
    const glued = !under && out.length > 0 && /^\S/.test(body);
    if (glued) {
      const m = body.match(/^\S+/)![0];
      out[out.length - 1].tail = (out[out.length - 1].tail ?? "") + m;
      body = body.slice(m.length);
    }
    const words = body.split(/\s+/).filter(Boolean);
    words.forEach((word, k) => out.push({ word, under, joinNext: under && k < words.length - 1 }));
  });
  return out;
};

/** A hand-drawn ballpoint stroke, stretched under a word. */
const Underline: React.FC<{ color: string; seed: string; extend: boolean }> = ({ color, seed, extend }) => {
  const r = (k: number) => random(`${seed}-${k}`);
  const y0 = 5 + r(1) * 3;
  const y1 = 4 + r(2) * 5;
  const y2 = 5 + r(3) * 4;
  return (
    <svg
      viewBox="0 0 100 14"
      preserveAspectRatio="none"
      width="100%"
      height="14"
      style={{ position: "absolute", left: -6 + r(4) * 4, width: `calc(100% + ${f1(6 - r(4) * 4)}px + ${extend ? "0.42em" : `${f1(4 + r(5) * 6)}px`})`, bottom: "-0.02em", overflow: "visible" }}
    >
      <path
        d={`M0 ${f1(y0)} C 30 ${f1(y1)}, 62 ${f1(y2 + 1.5)}, 100 ${f1(y1 - 0.5)}`}
        fill="none"
        stroke={color}
        strokeWidth={3.4}
        strokeLinecap="round"
        vectorEffect="non-scaling-stroke"
        opacity={0.9}
      />
    </svg>
  );
};

/** Handwritten text with per-word jitter and inked underlines. */
const Ink: React.FC<{ text: string; color: string; font: string; size: number; seed: string; prefix?: string; style?: React.CSSProperties }> = ({
  text,
  color,
  font,
  size,
  seed,
  prefix,
  style,
}) => {
  const tokens = tokenize(text);
  if (prefix) tokens.unshift({ word: prefix, under: false, joinNext: false });
  return (
    <div style={{ fontFamily: font, fontSize: size, lineHeight: 1.32, color, filter: "url(#ink-bleed)", ...style }}>
      {tokens.map((t, k) => (
        <React.Fragment key={k}>
          <span
            style={{
              position: "relative",
              display: "inline-block",
              transform: `translateY(${f1((random(`${seed}-y${k}`) - 0.5) * size * 0.06)}px) rotate(${f1((random(`${seed}-r${k}`) - 0.5) * 2.4)}deg)`,
              WebkitTextStroke: `${f1(size * 0.012)}px ${color}`,
            }}
          >
            <span style={{ position: "relative", display: "inline-block" }}>
              {t.word}
              {t.under ? <Underline color={color} seed={`${seed}-u${k}`} extend={t.joinNext} /> : null}
            </span>
            {t.tail}
          </span>{" "}
        </React.Fragment>
      ))}
    </div>
  );
};

/* ------------------------------------------------------------------------ */
/* Episode card                                                              */
/* ------------------------------------------------------------------------ */

const EpisodeCard: React.FC<{ image?: string; mood: keyof typeof MOOD_FILTER; label: string; title: string; handle: string; length: string; ink: string; font: string }> = ({
  image,
  mood,
  label,
  title,
  handle,
  length,
  ink,
  font,
}) => (
  <div style={{ background: "#141414", borderRadius: 16, padding: 26, fontFamily: uiFont, color: "#F4F1EA", boxShadow: "0 1px 0 rgba(0,0,0,0.25)" }}>
    <div style={{ display: "flex", gap: 20 }}>
      <div style={{ width: 150, height: 150, borderRadius: 8, overflow: "hidden", flexShrink: 0, background: "#2A2A2C", position: "relative" }}>
        {image ? (
          <Img src={resolve(image)} style={{ width: "100%", height: "100%", objectFit: "cover", filter: `${MOOD_FILTER[mood]} contrast(1.1)` }} />
        ) : (
          <div style={{ position: "absolute", inset: 0, background: "#E9E3D5", display: "flex", alignItems: "center", justifyContent: "center" }}>
            <div style={{ fontFamily: font, fontSize: 54, color: ink, transform: "rotate(-6deg)" }}>ep.</div>
          </div>
        )}
      </div>
      <div style={{ display: "flex", flexDirection: "column", justifyContent: "center", minWidth: 0 }}>
        <div style={{ fontSize: 17, letterSpacing: 1.8, fontWeight: 600, color: "#9A968E" }}>{label}</div>
        <div style={{ fontSize: 30, lineHeight: 1.2, fontWeight: 600, marginTop: 6, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
          {title}
        </div>
        <div style={{ fontSize: 21, color: "#9A968E", marginTop: 8 }}>{handle}</div>
      </div>
    </div>
    <div style={{ display: "flex", alignItems: "center", gap: 18, marginTop: 22 }}>
      <div style={{ flex: 1 }}>
        <div style={{ position: "relative", height: 5, borderRadius: 3, background: "#3A3A3C" }}>
          <div style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: "34%", borderRadius: 3, background: "#F4F1EA" }} />
          <div style={{ position: "absolute", left: "34%", top: -5, width: 15, height: 15, marginLeft: -7, borderRadius: 8, background: "#F4F1EA" }} />
        </div>
        <div style={{ display: "flex", justifyContent: "space-between", fontSize: 17, color: "#9A968E", marginTop: 10, fontVariantNumeric: "tabular-nums" }}>
          <span>12:56</span>
          <span>{length}</span>
        </div>
      </div>
      <div style={{ width: 58, height: 58, borderRadius: 29, background: "#F4F1EA", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
        <svg width="22" height="24" viewBox="0 0 22 24">
          <path d="M3 2 L20 12 L3 22 Z" fill="#141414" />
        </svg>
      </div>
    </div>
  </div>
);

/* ------------------------------------------------------------------------ */
/* Layouts                                                                   */
/* ------------------------------------------------------------------------ */

/** Where the note sits on each body slide: a hand-placed rhythm, not a grid. */
const SPOTS: { x: number; y: number; w: number; rot: number; variant: NoteVariant }[] = [
  { x: 548, y: 700, w: 600, rot: -2.2, variant: "tape" },
  { x: 560, y: 610, w: 620, rot: 1.6, variant: "plain" },
  { x: 520, y: 780, w: 590, rot: -1.1, variant: "torn" },
  { x: 570, y: 650, w: 610, rot: 2.4, variant: "corners" },
  { x: 530, y: 720, w: 600, rot: -2.8, variant: "plain" },
  { x: 556, y: 680, w: 560, rot: 1.2, variant: "tape" },
  { x: 540, y: 740, w: 620, rot: -1.6, variant: "torn" },
  { x: 548, y: 640, w: 600, rot: 2, variant: "corners" },
];

export const InkNotesCarousel: React.FC<InkNotesCarouselProps> = ({
  handle,
  coverTitle,
  coverImage,
  thoughts,
  afterword,
  images,
  closingLead,
  closingImage,
  episodeLabel,
  episodeTitle,
  episodeLength,
  paperColor,
  inkColor,
  groundColor,
  tapeColor,
  photoMood,
  brandKit,
}) => {
  const slide = useCurrentFrame();
  const { durationInFrames } = useVideoConfig();
  const brandFont = useBrandFont(brandKit?.fonts?.[0]);
  const hand = brandFont ?? handFont;
  const ink = brandKit?.colors?.[0] ?? inkColor;
  const hasAfter = afterword.trim().length > 0;
  const bodyCount = thoughts.length + (hasAfter ? 1 : 0);
  const last = Math.max(durationInFrames - 1, bodyCount + 1);

  let ground: string | undefined;
  let content: React.ReactNode;

  if (slide <= 0) {
    ground = coverImage;
    const lines = coverTitle.split("\n").filter((l) => l.trim());
    content = (
      <div style={{ position: "absolute", left: 80, top: 470, display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 14 }}>
        {lines.map((line, k) => (
          <div
            key={k}
            style={{
              marginLeft: [0, 46, 18, 60, 8][k % 5],
              transform: `rotate(${f1((random(`cover-${k}`) - 0.5) * 3.6)}deg)`,
              filter: "drop-shadow(0 2px 1.5px rgba(0,0,0,0.4)) drop-shadow(0 12px 18px rgba(0,0,0,0.4))",
            }}
          >
            <div style={{ background: paperColor, padding: "10px 30px 14px 26px" }}>
              <Ink text={line} color={ink} font={hand} size={84} seed={`cover-l${k}`} style={{ whiteSpace: "nowrap" }} />
            </div>
          </div>
        ))}
        <div style={{ marginLeft: 30, marginTop: 34, transform: "rotate(-1.5deg)", filter: "drop-shadow(0 8px 12px rgba(0,0,0,0.45))" }}>
          <div style={{ background: paperColor, padding: "6px 20px 8px" }}>
            <Ink text={handle} color={ink} font={hand} size={38} seed="cover-handle" />
          </div>
        </div>
      </div>
    );
  } else if (slide >= last) {
    ground = closingImage;
    content = (
      <Note x={540} y={690} w={760} rot={-1.4} seed="close" paper={paperColor} tape={tapeColor} variant="tape" pad="56px 50px 50px">
        <Ink text={closingLead} color={ink} font={hand} size={58} seed="close-lead" />
        <div style={{ marginTop: 34 }}>
          <EpisodeCard image={closingImage} mood={photoMood} label={episodeLabel} title={episodeTitle} handle={handle} length={episodeLength} ink={ink} font={hand} />
        </div>
      </Note>
    );
  } else {
    const i = slide - 1;
    const isAfter = i >= thoughts.length;
    const text = isAfter ? afterword : thoughts[i];
    const spot = SPOTS[i % SPOTS.length];
    ground = images[i];
    content = (
      <Note x={spot.x} y={spot.y} w={spot.w} rot={spot.rot} seed={`n${i}`} paper={paperColor} tape={tapeColor} variant={spot.variant}>
        <Ink text={text} color={ink} font={hand} size={text.length > 80 ? 56 : 62} seed={`n${i}`} prefix={isAfter ? undefined : `${i + 1}.`} />
      </Note>
    );
  }

  return (
    <AbsoluteFill style={{ background: groundColor }}>
      {/* shared ink filter: softens the font into a ballpoint line that grabs the paper */}
      <svg width="0" height="0" style={{ position: "absolute" }}>
        <filter id="ink-bleed" x="-5%" y="-5%" width="110%" height="110%">
          <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="3" result="n" />
          <feDisplacementMap in="SourceGraphic" in2="n" scale="1.6" xChannelSelector="R" yChannelSelector="G" />
        </filter>
      </svg>
      <Ground src={ground || undefined} mood={photoMood} color={groundColor} seed={slide + 1} />
      {content}
    </AbsoluteFill>
  );
};

Remotion code you can paste anywhere, or install it: npx cutship add ink-notes-carousel

MIT License: use it in any video, personal or commercial. License

More from Cutship

Creator page →