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

Carousel · 4:5

Marker Scrawl Carousel

by CutshipVerified

An event announcement in the language of a cool restaurant feed: direct-flash food and drink photos, your name scrawled across them in electric-blue marker, tiny letter-spaced caps, huge grotesk words with the time scribbled over them, and a cream menu with hand-drawn wine glass, fork and olive doodles.

0 taken · 1 views

Included with Pro

Sign up free to Make this

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

components/remotion/marker-scrawl-carousel/component.tsx

import React, { useEffect, useState } from "react";
import { AbsoluteFill, Img, continueRender, delayRender, random, staticFile, useCurrentFrame } from "remotion";
import { loadFont as loadKristi } from "@remotion/google-fonts/Kristi";
import { loadFont as loadNothing } from "@remotion/google-fonts/NothingYouCouldDo";
import { loadFont as loadReenie } from "@remotion/google-fonts/ReenieBeanie";
import { loadFont as loadRockSalt } from "@remotion/google-fonts/RockSalt";
import { loadFont as loadPermanent } from "@remotion/google-fonts/PermanentMarker";
import { loadFont as loadHomemade } from "@remotion/google-fonts/HomemadeApple";
import { loadFont as loadArchivoBlack } from "@remotion/google-fonts/ArchivoBlack";
import { loadFont as loadAnton } from "@remotion/google-fonts/Anton";
import { loadFont as loadBebas } from "@remotion/google-fonts/BebasNeue";
import { loadFont as loadBigShoulders } from "@remotion/google-fonts/BigShoulders";
import { loadFont as loadArchivo } from "@remotion/google-fonts/Archivo";
import { getAvailableFonts } from "@remotion/google-fonts";
import type { MarkerScrawlCarouselProps } from "./schema";

const opts = { weights: ["400"] as "400"[], subsets: ["latin"] as "latin"[] };
const SCRIPT: Record<string, { family: string; em: number }> = {
  Kristi: { family: loadKristi("normal", opts).fontFamily, em: 0.36 },
  "Nothing You Could Do": { family: loadNothing("normal", opts).fontFamily, em: 0.5 },
  "Reenie Beanie": { family: loadReenie("normal", opts).fontFamily, em: 0.36 },
  "Rock Salt": { family: loadRockSalt("normal", opts).fontFamily, em: 0.72 },
  "Permanent Marker": { family: loadPermanent("normal", opts).fontFamily, em: 0.5 },
  "Homemade Apple": { family: loadHomemade("normal", opts).fontFamily, em: 0.58 },
};
const DISPLAY: Record<string, { family: string; em: number; weight: number; track: string }> = {
  "Archivo Black": { family: loadArchivoBlack("normal", opts).fontFamily, em: 0.8, weight: 400, track: "-0.035em" },
  Anton: { family: loadAnton("normal", opts).fontFamily, em: 0.47, weight: 400, track: "-0.01em" },
  "Bebas Neue": { family: loadBebas("normal", opts).fontFamily, em: 0.4, weight: 400, track: "0em" },
  "Big Shoulders": { family: loadBigShoulders("normal", { weights: ["900"] as "900"[], subsets: ["latin"] as "latin"[] }).fontFamily, em: 0.5, weight: 900, track: "-0.01em" },
};
const { fontFamily: capsFont } = loadArchivo("normal", { weights: ["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);

/* ---------------------------------------------------------------- marker strokes */

type Pt = [number, number];

/** Catmull-Rom through the points so a few hand-placed points read as one fluid stroke. */
const smooth = (pts: Pt[], per = 9): Pt[] => {
  if (pts.length < 3) return pts;
  const out: Pt[] = [];
  for (let i = 0; i < pts.length - 1; i++) {
    const p0 = pts[Math.max(0, i - 1)];
    const p1 = pts[i];
    const p2 = pts[i + 1];
    const p3 = pts[Math.min(pts.length - 1, i + 2)];
    for (let k = 0; k < per; k++) {
      const t = k / per;
      const t2 = t * t;
      const t3 = t2 * t;
      const c = (a: number, b: number, cc: number, d: number) =>
        0.5 * (2 * b + (-a + cc) * t + (2 * a - 5 * b + 4 * cc - d) * t2 + (-a + 3 * b - 3 * cc + d) * t3);
      out.push([c(p0[0], p1[0], p2[0], p3[0]), c(p0[1], p1[1], p2[1], p3[1])]);
    }
  }
  out.push(pts[pts.length - 1]);
  return out;
};

/**
 * One marker stroke as a filled outline: heavier where the pen lands, thinner as it
 * lifts off, with a slow wobble in the width. Returns an SVG path string.
 */
const brush = (pts: Pt[], width: number, seed: string): string => {
  const s = smooth(pts);
  const n = s.length;
  const phase = random(`${seed}-ph`) * 6;
  const left: Pt[] = [];
  const right: Pt[] = [];
  const half: number[] = [];
  for (let i = 0; i < n; i++) {
    const a = s[Math.max(0, i - 1)];
    const b = s[Math.min(n - 1, i + 1)];
    const dx = b[0] - a[0];
    const dy = b[1] - a[1];
    const len = Math.hypot(dx, dy) || 1;
    const nx = -dy / len;
    const ny = dx / len;
    const t = i / Math.max(1, n - 1);
    const press = (0.62 + 0.38 * Math.sin(Math.PI * Math.min(1, t * 1.25 + 0.05))) * (1 - 0.32 * t);
    const wob = 1 + Math.sin(t * 9 + phase) * 0.1 + (random(`${seed}-${i}`) - 0.5) * 0.06;
    const h = (width / 2) * press * wob;
    half.push(h);
    left.push([s[i][0] + nx * h, s[i][1] + ny * h]);
    right.push([s[i][0] - nx * h, s[i][1] - ny * h]);
  }
  const body = `M${left.map(([x, y]) => `${f1(x)} ${f1(y)}`).join(" L")} L${right
    .reverse()
    .map(([x, y]) => `${f1(x)} ${f1(y)}`)
    .join(" L")} Z`;
  const cap = ([x, y]: Pt, r: number) => `M${f1(x - r)} ${f1(y)} a${f1(r)} ${f1(r)} 0 1 0 ${f1(2 * r)} 0 a${f1(r)} ${f1(r)} 0 1 0 ${f1(-2 * r)} 0`;
  return `${body} ${cap(s[0], half[0])} ${cap(s[n - 1], half[n - 1])}`;
};

/** Hand-wobbled ellipse that overshoots its start, like a quick circle around something. */
const loopPts = (cx: number, cy: number, rx: number, ry: number, seed: string, turns = 1.12, start = -2.2): Pt[] => {
  const steps = 26;
  return Array.from({ length: steps + 1 }, (_, k) => {
    const t = start + (k / steps) * Math.PI * 2 * turns;
    const w = 1 + (random(`${seed}-${k}`) - 0.5) * 0.05 + (k / steps) * 0.07;
    return [cx + Math.cos(t) * rx * w, cy + Math.sin(t) * ry * w] as Pt;
  });
};

type DoodleKind = "wineglass" | "fork" | "star" | "olive" | "martini" | "sparkle" | "arrow" | "swash";

/** Strokes for each doodle in a 100 x 100 box. */
const DOODLES: Record<DoodleKind, Pt[][]> = {
  wineglass: [
    [[27, 12], [25, 30], [32, 45], [50, 53], [67, 46], [74, 29], [72, 10]],
    [[26, 12], [49, 15], [73, 10]],
    [[28, 28], [50, 32], [73, 27]],
    [[50, 53], [50.5, 72], [49.5, 88]],
    [[33, 91], [50, 87], [68, 90]],
  ],
  fork: [
    [[51, 97], [50.5, 72], [50, 50]],
    [[33, 8], [32, 30], [36, 44], [50, 51], [64, 44], [68, 30], [67, 9]],
    [[45, 6], [44.5, 26], [45, 46]],
    [[56, 6], [56.5, 26], [55.5, 47]],
  ],
  star: [
    [[50, 8], [61, 38], [93, 39], [67, 58], [77, 90], [50, 70], [23, 90], [33, 58], [7, 38], [40, 37], [51, 10]],
  ],
  olive: [
    loopPts(46, 56, 25, 31, "olive", 1.08, -1.4),
    loopPts(47, 50, 8, 7, "pit", 1.1, 0.4),
    [[88, 8], [64, 34], [36, 66], [18, 88]],
  ],
  martini: [
    [[10, 10], [30, 26], [50, 50]],
    [[50, 50], [70, 27], [91, 9]],
    [[10, 10], [50, 13], [91, 9]],
    [[50, 50], [50.5, 72], [49.5, 88]],
    [[32, 91], [50, 88], [69, 91]],
    loopPts(62, 24, 7, 6, "mo", 1.1),
    [[44, 2], [58, 22], [74, 38]],
  ],
  sparkle: [
    [[50, 8], [50.5, 50], [50, 92]],
    [[10, 51], [50, 49.5], [90, 50]],
    [[24, 24], [50, 50], [76, 77]],
  ],
  arrow: [
    [[8, 14], [20, 52], [48, 80], [86, 88]],
    [[66, 70], [87, 88], [64, 100]],
  ],
  swash: [[[2, 60], [28, 46], [60, 44], [86, 36], [98, 28]]],
};

const Doodle: React.FC<{
  kind: DoodleKind;
  x: number;
  y: number;
  size: number;
  rot?: number;
  color: string;
  weight?: number;
  flip?: boolean;
}> = ({ kind, x, y, size, rot = 0, color, weight = 6, flip }) => (
  <svg
    width={size}
    height={size}
    viewBox="0 0 100 100"
    style={{ position: "absolute", left: x, top: y, overflow: "visible", transform: `rotate(${rot}deg)${flip ? " scaleX(-1)" : ""}`, filter: "url(#ms-ink)" }}
  >
    {DOODLES[kind].map((s, k) => (
      <path key={k} d={brush(s, weight, `${kind}-${k}-${x}`)} fill={color} />
    ))}
  </svg>
);

/** A quick loop drawn around a box (page coordinates). */
const Loop: React.FC<{ x: number; y: number; w: number; h: number; color: string; seed: string; weight?: number; rot?: number }> = ({
  x,
  y,
  w,
  h,
  color,
  seed,
  weight = 9,
  rot = 0,
}) => (
  <svg
    width={w}
    height={h}
    style={{ position: "absolute", left: x, top: y, overflow: "visible", transform: `rotate(${rot}deg)`, filter: "url(#ms-ink)" }}
  >
    <path d={brush(loopPts(w / 2, h / 2, w / 2, h / 2, seed), weight, seed)} fill={color} />
  </svg>
);

/* ---------------------------------------------------------------- surfaces */

const InkFilter: React.FC = () => (
  <svg width="0" height="0" style={{ position: "absolute" }}>
    <filter id="ms-ink" x="-10%" y="-10%" width="120%" height="120%">
      <feTurbulence type="fractalNoise" baseFrequency="0.022" numOctaves="2" seed="3" result="wob" />
      <feDisplacementMap in="SourceGraphic" in2="wob" scale="5" xChannelSelector="R" yChannelSelector="G" result="w" />
      <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="1" seed="8" result="grit" />
      <feDisplacementMap in="w" in2="grit" scale="2.2" xChannelSelector="R" yChannelSelector="G" />
    </filter>
  </svg>
);

const Grain: React.FC<{ strength: number }> = ({ strength }) => (
  <svg width="1080" height="1350" style={{ position: "absolute", inset: 0, mixBlendMode: "overlay", opacity: strength, pointerEvents: "none" }}>
    <filter id="ms-grain">
      <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed="21" />
      <feColorMatrix type="saturate" values="0" />
    </filter>
    <rect width="1080" height="1350" filter="url(#ms-grain)" />
  </svg>
);

/** Full-bleed photo with a direct-flash grade (hard contrast, falloff at the edges). */
const FlashPhoto: React.FC<{ src: string; position?: string; dark?: number }> = ({ src, position = "50% 50%", dark = 0 }) => (
  <>
    <Img
      src={resolve(src)}
      style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", objectPosition: position, filter: "contrast(1.1) saturate(1.08) brightness(1.02)" }}
    />
    <div
      style={{
        position: "absolute",
        inset: 0,
        background: `radial-gradient(ellipse 78% 70% at 50% 46%, rgba(0,0,0,0) 52%, rgba(0,0,0,${0.42 + dark}) 100%)`,
      }}
    />
    <div
      style={{
        position: "absolute",
        inset: 0,
        background: `linear-gradient(rgba(0,0,0,${0.3 + dark}) 0%, rgba(0,0,0,${dark}) 20%, rgba(0,0,0,${dark}) 76%, rgba(0,0,0,${0.32 + dark}) 100%)`,
      }}
    />
  </>
);

/* ---------------------------------------------------------------- component */

export const MarkerScrawlCarousel: React.FC<MarkerScrawlCarouselProps> = ({
  wordmark,
  topLine,
  coverLine,
  coverScrawl,
  dayWords,
  dayScrawl,
  photoNote,
  photoCaps,
  offerWords,
  offerScrawl,
  circleNote,
  circleCaps,
  menuTitle,
  menuItems,
  menuNote,
  closingScrawl,
  closingDetails,
  closingCta,
  coverImage,
  images,
  inkColor,
  paperColor,
  lightColor,
  darkColor,
  scriptFont,
  displayFont,
  markerWeight,
  brandKit,
}) => {
  const slide = useCurrentFrame();
  const brandScript = useBrandFont(brandKit?.fonts?.[0]);
  const brandDisplay = useBrandFont(brandKit?.fonts?.[1]);
  const ink = brandKit?.colors?.[0] ?? inkColor;
  const script = brandScript ? { family: brandScript, em: 0.45 } : SCRIPT[scriptFont] ?? SCRIPT.Kristi;
  const display = brandDisplay ? { family: brandDisplay, em: 0.66, weight: 900, track: "-0.02em" } : DISPLAY[displayFont] ?? DISPLAY["Archivo Black"];

  const pics = [coverImage, ...images].map((p) => (p ?? "").trim());
  const photoFor = (n: number) => pics[n] || "";

  /** Fit script text to a width (rough per-font character widths). */
  const fitScript = (text: string, max: number, width: number) => Math.min(max, width / Math.max(1, text.length * script.em));

  const Scrawl: React.FC<{ text: string; size: number; x?: number; y?: number; rot?: number; style?: React.CSSProperties; color?: string }> = ({
    text,
    size,
    x,
    y,
    rot = -6,
    style,
    color = ink,
  }) => (
    <div
      style={{
        position: x === undefined ? "relative" : "absolute",
        left: x,
        top: y,
        fontFamily: script.family,
        fontSize: size,
        lineHeight: 1,
        color,
        WebkitTextStroke: `${f1((size / 100) * markerWeight)}px ${color}`,
        paintOrder: "stroke fill",
        transform: `rotate(${rot}deg)`,
        whiteSpace: "nowrap",
        filter: "url(#ms-ink)",
        ...style,
      }}
    >
      {text}
    </div>
  );

  const Caps: React.FC<{ text: string; color: string; size?: number; style?: React.CSSProperties; shadow?: boolean }> = ({ text, color, size = 21, style, shadow }) => (
    <div
      style={{
        fontFamily: capsFont,
        fontWeight: 600,
        fontSize: size,
        letterSpacing: "0.34em",
        textTransform: "uppercase",
        color,
        textShadow: shadow ? "0 1px 3px rgba(0,0,0,0.35)" : undefined,
        whiteSpace: "nowrap",
        ...style,
      }}
    >
      {text}
    </div>
  );

  const TopLine: React.FC<{ onPhoto: boolean }> = ({ onPhoto }) => (
    <div style={{ position: "absolute", top: 58, left: 0, right: 0, display: "flex", justifyContent: "center" }}>
      <Caps text={topLine} color={onPhoto ? lightColor : darkColor} shadow={onPhoto} style={{ opacity: onPhoto ? 0.94 : 0.85, paddingLeft: "0.34em" }} />
    </div>
  );

  /** Small wordmark signature, like the logo scrawled on every tile. */
  const Signature: React.FC<{ x?: number; y: number; size?: number; rot?: number; align?: "center" | "right" | "left" }> = ({
    y,
    size = 104,
    rot = -5,
    align = "center",
    x = 0,
  }) => (
    <div
      style={{
        position: "absolute",
        top: y,
        left: align === "left" ? x : 0,
        right: align === "right" ? x : 0,
        width: align === "center" ? "100%" : undefined,
        display: "flex",
        justifyContent: align === "center" ? "center" : align === "right" ? "flex-end" : "flex-start",
      }}
    >
      <Scrawl text={wordmark} size={fitScript(wordmark, size, 560)} rot={rot} />
    </div>
  );

  /** Huge grotesk words, one word per line, sized to fill the width. */
  const BigWords: React.FC<{ text: string; color: string; children?: React.ReactNode; style?: React.CSSProperties; maxSize?: number }> = ({
    text,
    color,
    children,
    style,
    maxSize = 360,
  }) => {
    const words = text.trim().toUpperCase().split(/\s+/).filter(Boolean);
    const letter = (c: string) => (/[IJ1:.,'!|]/.test(c) ? 0.42 : /[MW]/.test(c) ? 1.12 : /[FLTEPSZ]/.test(c) ? 0.86 : 1);
    const widest = Math.max(1, ...words.map((w) => [...w].reduce((a, c) => a + letter(c), 0)));
    const size = Math.min(maxSize, 968 / (widest * display.em));
    return (
      <div style={{ position: "absolute", left: 54, right: 54, ...style }}>
        <div style={{ position: "relative", display: "inline-block" }}>
          {words.map((w, k) => (
            <div
              key={k}
              style={{
                fontFamily: display.family,
                fontWeight: display.weight,
                fontSize: size,
                lineHeight: 0.86,
                letterSpacing: display.track,
                color,
                whiteSpace: "nowrap",
              }}
            >
              {w}
            </div>
          ))}
          {children}
        </div>
      </div>
    );
  };

  const surface = (n: number, position?: string, dark?: number) => {
    const src = photoFor(n);
    return src ? <FlashPhoto src={src} position={position} dark={dark} /> : null;
  };

  let content: React.ReactNode;
  let onPhoto = false;

  if (slide <= 0) {
    /* ---------- cover: the wordmark, huge, scrawled over a flash photo */
    onPhoto = !!photoFor(0);
    const size = fitScript(wordmark, 420, 1000);
    content = (
      <>
        {surface(0, "50% 50%", 0.04)}
        {!onPhoto ? (
          <>
            <Doodle kind="wineglass" x={780} y={170} size={220} rot={12} color={ink} />
            <Doodle kind="fork" x={110} y={930} size={210} rot={-16} color={ink} />
            <Doodle kind="olive" x={800} y={950} size={160} rot={8} color={ink} />
            <Doodle kind="star" x={470} y={1010} size={90} rot={-8} color={ink} weight={7} />
          </>
        ) : null}
        <div style={{ position: "absolute", left: 0, right: 0, top: 640 - size * 0.62, display: "flex", justifyContent: "center" }}>
          <div style={{ position: "relative" }}>
            <Scrawl text={coverScrawl} size={fitScript(coverScrawl, 92, 480)} x={-10} y={-size * 0.5} rot={-9} />
            <Scrawl text={wordmark} size={size} rot={-7} />
            <Doodle kind="swash" x={size * 0.55} y={size * 0.5} size={Math.min(560, size * 1.5)} rot={-8} color={ink} weight={4} />
          </div>
        </div>
        <div style={{ position: "absolute", left: 60, right: 60, bottom: 74, display: "flex", justifyContent: "center" }}>
          <Caps text={coverLine} color={onPhoto ? lightColor : darkColor} shadow={onPhoto} size={20} />
        </div>
      </>
    );
  } else if (slide === 1) {
    /* ---------- big words + scrawled time */
    onPhoto = !!photoFor(1);
    const txt = onPhoto ? lightColor : darkColor;
    const sz = fitScript(dayScrawl, 250, 640);
    content = (
      <>
        {surface(1, "50% 40%", 0.06)}
        {!onPhoto ? <Doodle kind="wineglass" x={120} y={850} size={260} rot={-9} color={ink} weight={4.6} /> : null}
        <BigWords text={dayWords} color={txt} style={{ top: 300 }}>
          <Scrawl text={dayScrawl} size={sz} x={undefined} rot={-9} style={{ position: "absolute", right: -30, bottom: -sz * 0.62 }} />
        </BigWords>
        <Signature y={1170} size={96} />
      </>
    );
  } else if (slide === 2) {
    /* ---------- photo with a scrawled note and arrow */
    onPhoto = !!photoFor(2);
    content = (
      <>
        {surface(2, "50% 55%")}
        {!onPhoto ? <Doodle kind="olive" x={430} y={520} size={460} rot={6} color={ink} weight={3.4} /> : null}
        <Scrawl text={photoNote} size={fitScript(photoNote, 138, 760)} x={74} y={190} rot={-7} />
        <Doodle kind="arrow" x={240} y={330} size={280} rot={-4} color={ink} weight={6} />
        <div style={{ position: "absolute", left: 66, bottom: 82 }}>
          <Caps text={photoCaps} color={onPhoto ? lightColor : darkColor} shadow={onPhoto} size={19} />
        </div>
        <Signature y={1150} size={92} align="right" x={60} rot={-6} />
      </>
    );
  } else if (slide === 3) {
    /* ---------- big words at the bottom, scrawl over the top of them */
    onPhoto = !!photoFor(3);
    const txt = onPhoto ? lightColor : darkColor;
    const sz = fitScript(offerScrawl, 270, 600);
    content = (
      <>
        {surface(3, "50% 45%", 0.06)}
        {!onPhoto ? <Doodle kind="martini" x={590} y={190} size={330} rot={10} color={ink} weight={3.6} /> : null}
        <BigWords text={offerWords} color={txt} style={{ bottom: 150 }}>
          <Scrawl text={offerScrawl} size={sz} rot={-10} style={{ position: "absolute", right: 20, top: -sz * 0.62 }} />
        </BigWords>
        {onPhoto ? <Doodle kind="sparkle" x={70} y={170} size={86} rot={8} color={ink} weight={6} /> : null}
      </>
    );
  } else if (slide === 4) {
    /* ---------- photo with a hand-drawn circle around the moment */
    onPhoto = !!photoFor(4);
    const sz = fitScript(circleNote, 130, 820);
    content = (
      <>
        {surface(4, "50% 50%")}
        {!onPhoto ? <Doodle kind="wineglass" x={390} y={430} size={300} rot={-6} color={ink} weight={3.6} /> : null}
        <Loop x={230} y={360} w={620} h={470} color={ink} seed="ring" weight={15} rot={-8} />
        <div style={{ position: "absolute", left: 0, right: 0, top: 880, display: "flex", justifyContent: "center" }}>
          <Scrawl text={circleNote} size={sz} rot={-5} />
        </div>
        <div style={{ position: "absolute", left: 60, right: 60, bottom: 78, display: "flex", justifyContent: "center" }}>
          <Caps text={circleCaps} color={onPhoto ? lightColor : darkColor} shadow={onPhoto} size={19} />
        </div>
      </>
    );
  } else if (slide === 5) {
    /* ---------- cream menu with doodles */
    const n = menuItems.length;
    const nameSize = n <= 4 ? 70 : n === 5 ? 62 : 52;
    const gap = n <= 4 ? 34 : n === 5 ? 26 : 20;
    content = (
      <>
        <Doodle kind="wineglass" x={560} y={120} size={230} rot={-10} color={ink} />
        <Doodle kind="fork" x={800} y={100} size={230} rot={18} color={ink} />
        <Doodle kind="olive" x={840} y={380} size={150} rot={-6} color={ink} weight={7} />
        <Doodle kind="star" x={640} y={420} size={90} rot={12} color={ink} weight={8} />
        <Scrawl text={menuTitle} size={fitScript(menuTitle, 340, 560)} x={60} y={160} rot={-7} />
        <div style={{ position: "absolute", left: 76, right: 76, top: 560, display: "flex", flexDirection: "column", gap }}>
          {menuItems.map((m, k) => (
            <div key={k} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
              <div
                style={{
                  fontFamily: display.family,
                  fontWeight: display.weight,
                  fontSize: nameSize,
                  lineHeight: 0.95,
                  letterSpacing: display.track,
                  textTransform: "uppercase",
                  color: darkColor,
                }}
              >
                {m.name}
              </div>
              <Caps text={m.detail} color={darkColor} size={18} style={{ opacity: 0.72 }} />
            </div>
          ))}
        </div>
        <Doodle kind="arrow" x={690} y={1110} size={120} rot={200} color={ink} weight={6} />
        <Scrawl text={menuNote} size={fitScript(menuNote, 104, 420)} x={600} y={1200} rot={-6} />
      </>
    );
  } else {
    /* ---------- closing: see you there */
    onPhoto = !!photoFor(5);
    const lines = closingScrawl.trim().split(/\s+/);
    const mid = Math.ceil(lines.length / 2);
    const l1 = lines.slice(0, mid).join(" ");
    const l2 = lines.slice(mid).join(" ");
    const sz = fitScript(l1.length > l2.length ? l1 : l2, 270, 900);
    const txt = onPhoto ? lightColor : darkColor;
    content = (
      <>
        {surface(5, "50% 50%", 0.28)}
        {!onPhoto ? (
          <>
            <Doodle kind="star" x={110} y={170} size={110} rot={-12} color={ink} />
            <Doodle kind="sparkle" x={860} y={240} size={80} rot={10} color={ink} weight={6} />
          </>
        ) : null}
        <div style={{ position: "absolute", left: 0, right: 0, top: 260, display: "flex", flexDirection: "column", alignItems: "center" }}>
          <Scrawl text={l1} size={sz} rot={-7} style={{ marginLeft: -120 }} />
          {l2 ? <Scrawl text={l2} size={sz} rot={-7} style={{ marginLeft: 160, marginTop: -sz * 0.18 }} /> : null}
        </div>
        <div style={{ position: "absolute", left: 0, right: 0, top: 880, display: "flex", flexDirection: "column", alignItems: "center", gap: 18 }}>
          {closingDetails.map((d, k) => (
            <Caps key={k} text={d} color={txt} shadow={onPhoto} size={24} style={{ paddingLeft: "0.34em" }} />
          ))}
        </div>
        <div style={{ position: "absolute", left: 0, right: 0, top: 1080, display: "flex", justifyContent: "center" }}>
          <div style={{ position: "relative", padding: "30px 54px" }}>
            <Caps text={closingCta} color={txt} shadow={onPhoto} size={21} style={{ paddingLeft: "0.34em" }} />
            <Loop x={-6} y={-4} w={closingCta.length * 15 + 120} h={92} color={ink} seed="cta" weight={6} rot={-3} />
          </div>
        </div>
        <Signature y={1215} size={84} />
      </>
    );
  }

  return (
    <AbsoluteFill style={{ backgroundColor: paperColor, overflow: "hidden" }}>
      <InkFilter />
      {content}
      <TopLine onPhoto={onPhoto} />
      <Grain strength={onPhoto ? 0.22 : 0.3} />
    </AbsoluteFill>
  );
};

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

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

More from Cutship

Creator page →