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.
#streetflash#editorialminimal#lo-fizine#carousel#instagram#handwritten#ballpoint#papernote#blackandwhite#street#podcast#lessons#list#4:5
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 →