Innovations

Postcard Carousel — Storyteller

Postcard carousel layered onto a full-bleed hero photo backdrop with the section header (eyebrow + title) overlaid on the image. The polaroid postcards (each pinned with a colored tack) overlap into the cream paper area below. Avatar circle then sits above the cursive handle and serif name — magazine-style identity stack — followed by the freebie tile, latest letter, link buttons, and socials.

Preview

Source

tsx
"use client";

import { useEffect, useRef, useState } from "react";
import {
  Instagram,
  Twitter,
  Youtube,
  Mail,
  Globe,
  ShoppingBag,
  PlayCircle,
  Linkedin,
  Music2,
  ArrowUpRight,
  ChevronLeft,
  ChevronRight,
  type LucideIcon,
} from "lucide-react";
import type { GalleryItem, LinkIcon, LinksInBioData, RichBlock } from "../types";
import { defaultData } from "../defaultData";

const ICONS: Record<LinkIcon, LucideIcon> = {
  instagram: Instagram,
  twitter: Twitter,
  tiktok: Music2,
  youtube: Youtube,
  spotify: Music2,
  apple: Music2,
  linkedin: Linkedin,
  email: Mail,
  globe: Globe,
  shop: ShoppingBag,
  play: PlayCircle,
};

const PAPER = "#f5ecd9";
const CARD = "#fbf6e7";
const INK = "#2d231c";
const STAMP = "#a85a3c";
const RULE = "rgba(45,35,28,0.18)";

const TACKS = [
  { src: "/tacks/red-tack.png", side: "right", offset: 6 },
  { src: "/tacks/yellow-tack.png", side: "left", offset: 6 },
  { src: "/tacks/blue-tack.png", side: "right", offset: 6 },
] as const;

function Postcard({ item, rotation, index }: { item: GalleryItem; rotation: number; index: number }) {
  const tack = TACKS[index % TACKS.length];
  const tackTilt = index % 2 === 0 ? -8 : 6;
  return (
    <div
      className="relative shrink-0 snap-center"
      style={{
        background: "#ffffff",
        padding: "12px 12px 14px",
        boxShadow: "0 18px 36px rgba(45,35,28,0.18)",
        transform: `rotate(${rotation}deg)`,
        width: "260px",
      }}
    >
      <div className="relative overflow-hidden" style={{ aspectRatio: "5 / 4" }}>
        <img
          src={item.image}
          alt={item.caption ?? ""}
          width={800}
          height={640}
          loading="lazy"
          className="h-full w-full object-cover"
          style={{ filter: "saturate(0.92) contrast(1.02) sepia(0.05)" }}
        />
      </div>
      <img
        src={tack.src}
        alt=""
        aria-hidden="true"
        className="pointer-events-none absolute"
        style={{
          top: -18,
          [tack.side]: tack.offset,
          width: 60,
          height: "auto",
          overflow: "visible",
          transform: `rotate(${tackTilt}deg)`,
          filter: "drop-shadow(0 4px 6px rgba(45,35,28,0.4))",
          zIndex: 30,
        }}
      />
      <div className="mt-2 px-1">
        {item.caption && (
          <p
            className="text-base leading-tight"
            style={{
              fontFamily: "'Caveat', cursive",
              color: INK,
              fontSize: "1.2rem",
            }}
          >
            {item.caption}
          </p>
        )}
        {item.meta && (
          <p
            className="mt-0.5 line-clamp-2 text-[11px]"
            style={{ color: "rgba(45,35,28,0.65)" }}
          >
            {item.meta}
          </p>
        )}
      </div>
    </div>
  );
}

export interface LinksInBioPostcardCarouselStorytellerProps {
  data?: LinksInBioData;
}

export default function LinksInBioPostcardCarouselStoryteller({
  data = defaultData,
}: LinksInBioPostcardCarouselStorytellerProps) {
  const items: GalleryItem[] =
    (data.gallery && data.gallery.length > 0
      ? data.gallery
      : [{ image: data.heroImage ?? data.avatar, caption: "Home" }]);

  const heroImage = data.heroImage ?? data.avatar;

  const [active, setActive] = useState(0);
  const scrollRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    const onScroll = () => {
      const cards = el.querySelectorAll<HTMLDivElement>("[data-postcard]");
      let bestIdx = 0;
      let bestDist = Infinity;
      const center = el.scrollLeft + el.clientWidth / 2;
      cards.forEach((c, i) => {
        const cardCenter = c.offsetLeft + c.offsetWidth / 2;
        const d = Math.abs(cardCenter - center);
        if (d < bestDist) {
          bestDist = d;
          bestIdx = i;
        }
      });
      setActive(bestIdx);
    };
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, []);

  function scrollTo(idx: number) {
    const el = scrollRef.current;
    if (!el) return;
    const cards = el.querySelectorAll<HTMLDivElement>("[data-postcard]");
    const target = cards[idx];
    if (target) {
      el.scrollTo({
        left: target.offsetLeft - el.clientWidth / 2 + target.offsetWidth / 2,
        behavior: "smooth",
      });
    }
  }

  const freebie = data.richBlocks?.find(
    (b): b is Extract<RichBlock, { kind: "freebie" }> => b.kind === "freebie"
  );
  const latestPost = data.richBlocks?.find(
    (b): b is Extract<RichBlock, { kind: "latest-post" }> => b.kind === "latest-post"
  );

  return (
    <>
      <link rel="preconnect" href="https://fonts.googleapis.com" />
      <link
        rel="stylesheet"
        href="https://fonts.googleapis.com/css2?family=Caveat:[email protected]&family=Crimson+Pro:ital,wght@0,400..700;1,400..700&family=Inter:wght@400;500;600&display=swap"
      />
      <section
        className="min-h-screen w-full pb-12"
        style={{
          color: INK,
          fontFamily: "'Inter', sans-serif",
          backgroundColor: PAPER,
          backgroundImage: `linear-gradient(180deg, rgba(245,236,217,0.55) 0%, rgba(245,236,217,0.82) 55%, rgba(245,236,217,0.94) 100%), url(${heroImage})`,
          backgroundSize: "cover",
          backgroundPosition: "center top",
          backgroundAttachment: "fixed",
          backgroundRepeat: "no-repeat",
        }}
      >
        {/* Hero photo backdrop with eyebrow + title overlaid */}
        <div className="relative w-full overflow-hidden" style={{ height: "440px" }}>
          <img
            src={heroImage}
            alt=""
            width={1600}
            height={900}
            loading="eager"
            className="h-full w-full object-cover"
          />
          <div
            aria-hidden
            className="absolute inset-0"
            style={{
              background: `linear-gradient(180deg, rgba(45,35,28,0.35) 0%, rgba(45,35,28,0.05) 35%, ${PAPER} 100%)`,
            }}
          />
          <div className="absolute inset-x-0 top-8 px-5 text-center">
            <p
              className="text-[10px] font-semibold uppercase tracking-[0.3em]"
              style={{ color: "#fbf6e7", textShadow: "0 2px 8px rgba(0,0,0,0.4)" }}
            >
              Postcards from the road
            </p>
            <h2
              className="mt-1 text-2xl"
              style={{
                fontFamily: "'Crimson Pro', serif",
                fontWeight: 700,
                color: "#ffffff",
                textShadow: "0 2px 12px rgba(0,0,0,0.45)",
              }}
            >
              Where I've been writing from
            </h2>
          </div>
        </div>

        {/* Scrollable postcard rail — pulled up to overlap the hero photo */}
        <div className="relative" style={{ marginTop: "-260px" }}>
          <div
            ref={scrollRef}
            className="flex gap-5 overflow-x-auto px-[calc(50%-130px)] pb-16 pt-10 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden snap-x snap-mandatory"
            style={{ scrollSnapType: "x mandatory" }}
          >
            {items.map((item, i) => (
              <div key={i} data-postcard>
                <Postcard item={item} rotation={(i % 2 === 0 ? -1 : 1) * (1 + (i % 3))} index={i} />
              </div>
            ))}
          </div>

          {/* Desktop arrow controls */}
          <button
            type="button"
            aria-label="Previous postcard"
            onClick={() => scrollTo(Math.max(0, active - 1))}
            className="absolute left-3 top-1/2 hidden -translate-y-1/2 items-center justify-center rounded-full sm:flex"
            style={{
              width: 36,
              height: 36,
              background: CARD,
              border: `1px solid ${RULE}`,
              color: INK,
              boxShadow: "0 6px 14px rgba(45,35,28,0.12)",
            }}
          >
            <ChevronLeft className="h-4 w-4" />
          </button>
          <button
            type="button"
            aria-label="Next postcard"
            onClick={() => scrollTo(Math.min(items.length - 1, active + 1))}
            className="absolute right-3 top-1/2 hidden -translate-y-1/2 items-center justify-center rounded-full sm:flex"
            style={{
              width: 36,
              height: 36,
              background: CARD,
              border: `1px solid ${RULE}`,
              color: INK,
              boxShadow: "0 6px 14px rgba(45,35,28,0.12)",
            }}
          >
            <ChevronRight className="h-4 w-4" />
          </button>
        </div>

        {/* Pagination dots */}
        <div className="mt-2 flex items-center justify-center gap-2">
          {items.map((_, i) => (
            <button
              key={i}
              type="button"
              aria-label={`Postcard ${i + 1}`}
              onClick={() => scrollTo(i)}
              className="rounded-full transition-all"
              style={{
                width: i === active ? 22 : 8,
                height: 8,
                background: i === active ? STAMP : "rgba(45,35,28,0.25)",
              }}
            />
          ))}
        </div>

        <div className="mx-auto mt-8 w-full max-w-[460px] px-5">
          {/* Avatar circle */}
          <div className="flex flex-col items-center text-center">
            <img
              src={data.avatar}
              alt={data.name}
              width={140}
              height={140}
              loading="eager"
              className="h-32 w-32 rounded-full object-cover"
              style={{
                border: `5px solid ${PAPER}`,
                boxShadow: "0 18px 38px rgba(45,35,28,0.22)",
              }}
            />
          </div>

          {/* Cursive handle + name */}
          {data.handle && (
            <p
              className="mt-4 text-center"
              style={{
                fontFamily: "'Caveat', cursive",
                fontSize: "1.6rem",
                color: STAMP,
                lineHeight: 1.1,
              }}
            >
              {data.handle}
            </p>
          )}
          <h1
            className="mt-1 text-center"
            style={{
              fontFamily: "'Crimson Pro', serif",
              fontWeight: 700,
              fontSize: "clamp(2rem, 6.4vw, 2.6rem)",
              color: INK,
            }}
          >
            {data.name}
            {data.verified && (
              <span className="ml-2 align-middle text-base" style={{ color: STAMP }}>

              </span>
            )}
          </h1>
          {data.bio && (
            <p
              className="mx-auto mt-3 max-w-[400px] text-center text-[15px] leading-relaxed"
              style={{ color: "rgba(45,35,28,0.75)" }}
            >
              {data.bio}
            </p>
          )}

          {/* Dotted divider */}
          <div
            aria-hidden
            className="my-6 h-px w-full"
            style={{
              backgroundImage: `linear-gradient(to right, ${RULE} 50%, transparent 50%)`,
              backgroundSize: "8px 1px",
            }}
          />

          {/* Quick freebie tile */}
          {freebie && (
            <div
              className="rounded-xl p-5"
              style={{ background: CARD, border: `2px dashed ${STAMP}` }}
            >
              <p
                className="text-[10px] font-bold uppercase tracking-[0.3em]"
                style={{ color: STAMP }}
              >
                Postcard club
              </p>
              <p
                className="mt-1.5 text-lg leading-snug"
                style={{ fontFamily: "'Crimson Pro', serif", fontWeight: 600 }}
              >
                {freebie.title}
              </p>
              {freebie.description && (
                <p className="mt-1.5 text-sm" style={{ color: "rgba(45,35,28,0.7)" }}>
                  {freebie.description}
                </p>
              )}
              <p className="mt-2 text-[12px]" style={{ color: STAMP }}>
                A real paper postcard, mailed once a season. Free.
              </p>
            </div>
          )}

          {latestPost && (
            <a
              href={latestPost.href}
              className="mt-4 grid grid-cols-[110px_1fr] gap-3 rounded-xl p-3 transition-transform hover:-translate-y-0.5"
              style={{ background: CARD, border: `1px dashed ${RULE}` }}
            >
              <div className="overflow-hidden rounded-sm" style={{ aspectRatio: "1/1" }}>
                <img
                  src={latestPost.image}
                  alt=""
                  width={300}
                  height={300}
                  loading="lazy"
                  className="h-full w-full object-cover"
                />
              </div>
              <div className="flex flex-col justify-center">
                <p
                  className="text-[10px] font-semibold uppercase tracking-[0.25em]"
                  style={{ color: STAMP }}
                >
                  This week's letter
                </p>
                <p
                  className="mt-1 line-clamp-2 text-base leading-snug"
                  style={{ fontFamily: "'Crimson Pro', serif", fontWeight: 600 }}
                >
                  {latestPost.title}
                </p>
                {latestPost.meta && (
                  <p className="mt-1 text-[11px]" style={{ color: "rgba(45,35,28,0.6)" }}>
                    {latestPost.meta}
                  </p>
                )}
              </div>
            </a>
          )}

          {/* Stamped buttons */}
          <ul className="mt-5 space-y-2">
            {data.links.slice(0, 4).map((link) => {
              const Icon = link.icon ? ICONS[link.icon] : ArrowUpRight;
              return (
                <li key={link.label}>
                  <a
                    href={link.href}
                    className="flex items-center gap-3 rounded-lg px-4 py-3.5 transition-transform hover:translate-x-0.5"
                    style={{
                      background: CARD,
                      border: `1px dashed ${RULE}`,
                      color: INK,
                    }}
                  >
                    <span
                      className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
                      style={{ background: PAPER, color: STAMP }}
                    >
                      <Icon className="h-3.5 w-3.5" strokeWidth={1.8} />
                    </span>
                    <span className="flex-1 truncate text-[14px]">{link.label}</span>
                    {link.badge && (
                      <span
                        className="shrink-0 rounded-sm px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.18em]"
                        style={{ background: STAMP, color: PAPER }}
                      >
                        {link.badge}
                      </span>
                    )}
                    <ArrowUpRight className="h-4 w-4 shrink-0" style={{ color: STAMP, opacity: 0.7 }} />
                  </a>
                </li>
              );
            })}
          </ul>

          {data.socials && data.socials.length > 0 && (
            <div className="mt-7 flex flex-wrap items-center justify-center gap-3">
              {data.socials.map((s, i) => {
                const Icon = ICONS[s.type] ?? Globe;
                return (
                  <a
                    key={i}
                    href={s.href}
                    aria-label={s.type}
                    className="flex h-9 w-9 items-center justify-center rounded-full transition-transform hover:-rotate-6"
                    style={{
                      background: CARD,
                      border: `1.5px solid ${STAMP}`,
                      color: STAMP,
                    }}
                  >
                    <Icon className="h-4 w-4" strokeWidth={1.8} />
                  </a>
                );
              })}
            </div>
          )}

          <p
            className="mt-7 text-center"
            style={{
              fontFamily: "'Caveat', cursive",
              fontSize: "1.1rem",
              color: STAMP,
              opacity: 0.8,
            }}
          >
            xo, from somewhere.
          </p>
        </div>
      </section>
    </>
  );
}
Claude Code Instructions

CLI Install

npx innovations add postcard-carousel-storyteller

Where to use it

A storyteller-flavored postcard carousel. Combines the swipeable polaroid rail of postcard-carousel with the storyteller pattern of a hero image at top + circular avatar overlap above the name. Loads Caveat, Crimson Pro, and Inter from Google Fonts. Pass a typed 'data' prop (LinksInBioData from src/registry/links-in-bio/types.ts). Provide: - heroImage — wide image used as the backdrop the postcards float on top of (falls back to avatar) - avatar — circular portrait that sits above the name - gallery — array of { image, caption, meta } for the postcard rail - richBlocks — opt into freebie / latest-post tiles below the rail Tack PNGs are served from /tacks/{red,yellow,blue}-tack.png in /public. In Astro: import LinksInBioPostcardCarouselStoryteller from '../components/innovations/links-in-bio/postcard-carousel-storyteller'; <LinksInBioPostcardCarouselStoryteller client:load data={myProfile} /> In Next.js: import LinksInBioPostcardCarouselStoryteller from '@/components/innovations/links-in-bio/postcard-carousel-storyteller'; Best for: travel writers, photographers, and lifestyle authors who want a stronger sense of place — the hero photo establishes mood, the polaroids stack on top, and the avatar/name identity block reads like a magazine masthead.