Innovations

Postcard Carousel

Postcard variant with a horizontally swipeable rail of multiple postcards (each with its own perforated city stamp + handwritten caption). Native scroll-snap on mobile, arrow controls on desktop, and dot pagination. Below the carousel: cursive handle, name, dotted dividers, postcard-club freebie tile, latest letter card, and stamped link buttons.

Preview

Source

tsx
"use client";

import { useEffect, useRef, useState } from "react";
import {
  Instagram,
  Twitter,
  Youtube,
  Mail,
  Globe,
  ShoppingBag,
  PlayCircle,
  Linkedin,
  Music2,
  ArrowUpRight,
  ChevronLeft,
  ChevronRight,
  Stamp,
  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)";

function Postcard({ item, rotation }: { item: GalleryItem; rotation: number }) {
  return (
    <div
      className="relative shrink-0 snap-center"
      style={{
        background: CARD,
        padding: "12px 12px 14px",
        boxShadow: "0 18px 36px rgba(45,35,28,0.18)",
        transform: `rotate(${rotation}deg)`,
        width: "260px",
      }}
    >
      <div
        className="absolute -right-2 -top-2 flex h-12 w-10 -rotate-6 flex-col items-center justify-center text-center"
        style={{
          background: PAPER,
          borderColor: STAMP,
          color: STAMP,
          boxShadow: "0 4px 8px rgba(45,35,28,0.12)",
          backgroundImage: `radial-gradient(circle at 0 50%, transparent 3px, ${PAPER} 3.5px), radial-gradient(circle at 100% 50%, transparent 3px, ${PAPER} 3.5px)`,
          backgroundSize: "6px 8px, 6px 8px",
          backgroundRepeat: "repeat-y",
          backgroundPosition: "left, right",
        }}
      >
        <Stamp className="h-3 w-3" strokeWidth={1.6} />
        <span className="mt-0.5 text-[8px] font-bold uppercase tracking-[0.15em]">
          {item.caption ?? ""}
        </span>
      </div>
      <div className="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>
      <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 LinksInBioPostcardCarouselProps {
  data?: LinksInBioData;
}

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

  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:wght@400..700&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 pt-8"
        style={{
          background: PAPER,
          color: INK,
          fontFamily: "'Inter', sans-serif",
          backgroundImage: `radial-gradient(${INK}10 1px, transparent 1px)`,
          backgroundSize: "16px 16px",
        }}
      >
        {/* Carousel header */}
        <div className="mx-auto mb-3 w-full max-w-[460px] px-5">
          <p
            className="text-[10px] font-semibold uppercase tracking-[0.3em]"
            style={{ color: STAMP }}
          >
            Postcards from the road
          </p>
          <h2
            className="mt-1 text-2xl"
            style={{
              fontFamily: "'Crimson Pro', serif",
              fontWeight: 700,
              color: INK,
            }}
          >
            Where I've been writing from
          </h2>
        </div>

        {/* Scrollable postcard rail */}
        <div className="relative">
          <div
            ref={scrollRef}
            className="flex gap-5 overflow-x-auto px-[calc(50%-130px)] pb-6 pt-4 [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))} />
              </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-6 w-full max-w-[460px] px-5">
          {/* Cursive handle + name */}
          {data.handle && (
            <p
              className="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

Where to use it

An evolved postcard variant with a swipeable rail of postcards from multiple cities. Loads Caveat, Crimson Pro, and Inter from Google Fonts. Pass a typed 'data' prop (LinksInBioData from src/registry/links-in-bio/types.ts). Provide: - gallery — array of { image, caption, meta } where caption renders as the city stamp and meta as the handwritten note - richBlocks — opt into freebie / latest-post tiles below the rail In Astro: import LinksInBioPostcardCarousel from '../components/innovations/links-in-bio/postcard-carousel'; <LinksInBioPostcardCarousel client:load data={myProfile} /> In Next.js: import LinksInBioPostcardCarousel from '@/components/innovations/links-in-bio/postcard-carousel'; Best for: travel writers, expats, lifestyle creators with a "from the road" vibe. Each postcard is slightly rotated (alternating tilt) for a tactile feel.