Postcard

Romantic / travel-writer feel — top photo framed as a real postcard with a perforated stamp corner reading

Preview

Source

tsx
"use client";

import { useState } from "react";
import {
  Instagram,
  Twitter,
  Youtube,
  Mail,
  Globe,
  ShoppingBag,
  PlayCircle,
  Linkedin,
  Music2,
  ArrowUpRight,
  Stamp,
  Users,
  CheckCircle2,
  type LucideIcon,
} from "lucide-react";
import type { 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 DottedDivider() {
  return (
    <div
      aria-hidden
      className="my-6 h-px w-full"
      style={{
        backgroundImage: `linear-gradient(to right, ${RULE} 50%, transparent 50%)`,
        backgroundSize: "8px 1px",
      }}
    />
  );
}

function PostcardFrame({ image, alt }: { image: string; alt: string }) {
  return (
    <div
      className="relative mx-auto w-full max-w-[420px] -rotate-1"
      style={{ background: CARD, padding: "14px 14px 18px", boxShadow: "0 22px 44px rgba(45,35,28,0.18), 0 2px 0 rgba(255,255,255,0.4) inset" }}
    >
      {/* postage stamp corner */}
      <div
        className="absolute -right-2 -top-2 flex h-14 w-12 -rotate-6 flex-col items-center justify-center text-center"
        style={{
          background: PAPER,
          borderColor: STAMP,
          color: STAMP,
          boxShadow: "0 4px 10px rgba(45,35,28,0.12)",
          // perforated stamp edge
          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-4 w-4" strokeWidth={1.6} />
        <span className="mt-0.5 text-[8px] font-bold uppercase tracking-[0.15em]">Lisbon</span>
      </div>

      <div
        className="overflow-hidden"
        style={{ aspectRatio: "5 / 4", background: "#ddd" }}
      >
        <img
          src={image}
          alt={alt}
          width={1200}
          height={960}
          loading="eager"
          className="h-full w-full object-cover"
          style={{ filter: "saturate(0.92) contrast(1.02) sepia(0.05)" }}
        />
      </div>
    </div>
  );
}

function BookTile({ block }: { block: Extract<RichBlock, { kind: "book" }> }) {
  return (
    <a
      href={block.href}
      className="group flex gap-4 rounded-xl p-4 transition-all hover:-translate-y-0.5"
      style={{ background: CARD, border: `1px dashed ${RULE}` }}
    >
      <div
        className="overflow-hidden rounded-sm"
        style={{ width: "84px", height: "126px", boxShadow: "0 12px 24px rgba(45,35,28,0.22)" }}
      >
        <img
          src={block.cover}
          alt={block.title}
          width={300}
          height={450}
          loading="lazy"
          className="h-full w-full object-cover"
        />
      </div>
      <div className="flex min-w-0 flex-1 flex-col">
        <p className="text-[10px] font-semibold uppercase tracking-[0.25em]" style={{ color: STAMP }}>
          Currently writing · Pre-order
        </p>
        <p
          className="mt-1 text-base leading-snug"
          style={{ fontFamily: "'Crimson Pro', serif", fontWeight: 600, color: INK }}
        >
          {block.title}
        </p>
        {block.blurb && (
          <p
            className="mt-1 line-clamp-2 text-xs"
            style={{ color: "rgba(45,35,28,0.7)" }}
          >
            {block.blurb}
          </p>
        )}
        <div className="mt-auto flex items-center justify-between pt-2">
          {block.price && (
            <span className="text-sm font-bold" style={{ color: INK }}>
              {block.price}
            </span>
          )}
          <span
            className="inline-flex items-center gap-1 text-[12px] font-semibold uppercase tracking-[0.18em]"
            style={{ color: STAMP }}
          >
            Pre-order <ArrowUpRight className="h-3.5 w-3.5" />
          </span>
        </div>
      </div>
    </a>
  );
}

function FreebieTile({ block }: { block: Extract<RichBlock, { kind: "freebie" }> }) {
  const [email, setEmail] = useState("");
  const [submitted, setSubmitted] = useState(false);
  return (
    <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 }}
      >
        Join the journey
      </p>
      <p
        className="mt-1.5 text-lg leading-snug"
        style={{ fontFamily: "'Crimson Pro', serif", fontWeight: 600, color: INK }}
      >
        {block.title}
      </p>
      {block.description && (
        <p className="mt-1.5 text-sm" style={{ color: "rgba(45,35,28,0.7)" }}>
          {block.description}
        </p>
      )}
      {submitted ? (
        <div
          className="mt-3 flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm"
          style={{ background: "rgba(168,90,60,0.10)", color: INK }}
        >
          <CheckCircle2 className="h-4 w-4" style={{ color: STAMP }} />
          <span>Sent — check your inbox.</span>
        </div>
      ) : (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            if (email) setSubmitted(true);
          }}
          className="mt-3 flex flex-col gap-2 sm:flex-row"
        >
          <input
            type="email"
            required
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder={block.placeholder ?? "[email protected]"}
            className="min-w-0 flex-1 rounded-lg border bg-white/60 px-3 py-2.5 text-sm focus:border-[color:var(--stamp)] focus:outline-none"
            style={{ borderColor: RULE, ["--stamp" as never]: STAMP, color: INK }}
          />
          <button
            type="submit"
            className="shrink-0 rounded-lg px-4 py-2.5 text-sm font-semibold"
            style={{ background: STAMP, color: PAPER }}
          >
            {block.cta ?? "Send"}
          </button>
        </form>
      )}
      {block.socialProof && !submitted && (
        <p
          className="mt-2 flex items-center gap-1 text-[11px]"
          style={{ color: "rgba(45,35,28,0.55)" }}
        >
          <Users className="h-3 w-3" /> {block.socialProof}
        </p>
      )}
    </div>
  );
}

function LatestPostTile({
  block,
}: {
  block: Extract<RichBlock, { kind: "latest-post" }>;
}) {
  return (
    <a
      href={block.href}
      className="group block overflow-hidden rounded-xl transition-all hover:-translate-y-0.5"
      style={{ background: CARD, border: `1px dashed ${RULE}` }}
    >
      <div className="grid grid-cols-[110px_1fr] gap-3 p-3">
        <div
          className="overflow-hidden rounded-sm"
          style={{ aspectRatio: "1/1" }}
        >
          <img
            src={block.image}
            alt=""
            width={300}
            height={300}
            loading="lazy"
            className="h-full w-full object-cover"
            style={{ filter: "saturate(0.92) contrast(1.02)" }}
          />
        </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, color: INK }}
          >
            {block.title}
          </p>
          {block.meta && (
            <p className="mt-1 text-[11px]" style={{ color: "rgba(45,35,28,0.6)" }}>
              {block.meta}
            </p>
          )}
        </div>
      </div>
    </a>
  );
}

export interface LinksInBioPostcardProps {
  data?: LinksInBioData;
}

export default function LinksInBioPostcard({
  data = defaultData,
}: LinksInBioPostcardProps) {
  const heroImage = data.heroImage ?? data.avatar;
  const book = data.richBlocks?.find(
    (b): b is Extract<RichBlock, { kind: "book" }> => b.kind === "book"
  );
  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 px-4 pb-12 pt-8"
        style={{
          background: PAPER,
          color: INK,
          fontFamily: "'Inter', sans-serif",
          backgroundImage: `radial-gradient(${INK}10 1px, transparent 1px)`,
          backgroundSize: "16px 16px",
        }}
      >
        <div className="mx-auto w-full max-w-[460px]">
          {/* Postcard photo */}
          <PostcardFrame image={heroImage} alt={data.name} />

          {/* Cursive handle stamp */}
          {data.handle && (
            <p
              className="mt-3 text-center"
              style={{
                fontFamily: "'Caveat', cursive",
                fontSize: "1.6rem",
                color: STAMP,
                lineHeight: 1.1,
              }}
            >
              {data.handle}
            </p>
          )}

          {/* Name */}
          <h1
            className="mt-2 text-center"
            style={{
              fontFamily: "'Crimson Pro', serif",
              fontWeight: 700,
              fontSize: "clamp(2rem, 6.4vw, 2.6rem)",
              letterSpacing: "-0.01em",
              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>
          )}

          <DottedDivider />

          {/* Tile mix */}
          <div className="space-y-4">
            {book && <BookTile block={book} />}
            {freebie && <FreebieTile block={freebie} />}
            {latestPost && <LatestPostTile block={latestPost} />}
          </div>

          <DottedDivider />

          {/* Stamped link buttons */}
          <ul className="space-y-2">
            {data.links.slice(0, 4).map((link, i) => {
              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>

          {/* Socials */}
          {data.socials && data.socials.length > 0 && (
            <div className="mt-8 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-8 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

Where to use it

A nostalgic postcard / travel-writer link-in-bio page. Loads Caveat, Crimson Pro, and Inter from Google Fonts. Hardcoded warm cream/sepia palette. Pass a typed 'data' prop (LinksInBioData from src/registry/links-in-bio/types.ts). Provide: - heroImage — wide image for the postcard photo (5:4 crop, slight saturation/sepia treatment is applied) - richBlocks — opt into book / freebie / latest-post tiles - The stamp corner says 'Lisbon' by default; edit the component to swap it for your own city / postmark In Astro: import LinksInBioPostcard from '../components/innovations/links-in-bio/postcard'; <LinksInBioPostcard client:load data={myProfile} /> In Next.js: import LinksInBioPostcard from '@/components/innovations/links-in-bio/postcard'; Best for: travel writers, lifestyle creators, indie authors, slow-living brands. Use a strong landscape or portrait photo with character — the postcard frame leans into editorial film photography rather than studio-clean shots.