Innovations

Portrait Stack

Mini-website link-in-bio with a circular portrait on textured backdrop, then a sticky horizontal tab bar (Home / Book / Course / Podcast / Shop) with anchor scroll. Each section has its own visual treatment: book card with retailer pills, video preview placeholder + course bullets, 3-up podcast episode list, 2x2 product grid.

Preview

Source

tsx
"use client";

import { useEffect, useState } from "react";
import {
  Instagram,
  Twitter,
  Youtube,
  Mail,
  Globe,
  ShoppingBag,
  PlayCircle,
  Linkedin,
  Music2,
  ArrowUpRight,
  BookOpen,
  GraduationCap,
  Headphones,
  Home,
  Play,
  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 INK = "#1a1a22";
const PAPER = "#f4efe6";
const ACCENT = "#c8513b";
const SOFT = "#ece5d6";
const RULE = "rgba(26,26,34,0.10)";

type TabId = "home" | "book" | "course" | "podcast" | "shop";

const TABS: { id: TabId; label: string; Icon: LucideIcon }[] = [
  { id: "home", label: "Home", Icon: Home },
  { id: "book", label: "Book", Icon: BookOpen },
  { id: "course", label: "Course", Icon: GraduationCap },
  { id: "podcast", label: "Podcast", Icon: Headphones },
  { id: "shop", label: "Shop", Icon: ShoppingBag },
];

export interface LinksInBioPortraitStackProps {
  data?: LinksInBioData;
}

export default function LinksInBioPortraitStack({
  data = defaultData,
}: LinksInBioPortraitStackProps) {
  const [activeTab, setActiveTab] = useState<TabId>("home");

  useEffect(() => {
    if (typeof window === "undefined") return;
    const tabs: TabId[] = ["home", "book", "course", "podcast", "shop"];
    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting && entry.target.id) {
            setActiveTab(entry.target.id as TabId);
          }
        }
      },
      { rootMargin: "-30% 0px -60% 0px", threshold: 0 }
    );
    for (const id of tabs) {
      const el = document.getElementById(id);
      if (el) observer.observe(el);
    }
    return () => observer.disconnect();
  }, []);

  const book = data.richBlocks?.find(
    (b): b is Extract<RichBlock, { kind: "book" }> => b.kind === "book"
  );
  const program = data.richBlocks?.find(
    (b): b is Extract<RichBlock, { kind: "group-program" }> => b.kind === "group-program"
  );
  const podcast = data.richBlocks?.find(
    (b): b is Extract<RichBlock, { kind: "podcast-episode" }> => b.kind === "podcast-episode"
  );
  const product = data.richBlocks?.find(
    (b): b is Extract<RichBlock, { kind: "featured-product" }> => b.kind === "featured-product"
  );

  function handleTabClick(e: React.MouseEvent<HTMLAnchorElement>, id: TabId) {
    e.preventDefault();
    if (typeof document === "undefined") return;
    const el = document.getElementById(id);
    if (el) {
      const offset = 70;
      const y = el.getBoundingClientRect().top + window.scrollY - offset;
      window.scrollTo({ top: y, behavior: "smooth" });
    }
    setActiveTab(id);
  }

  return (
    <>
      <link rel="preconnect" href="https://fonts.googleapis.com" />
      <link
        rel="stylesheet"
        href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400..700;1,400..700&family=Inter:wght@400;500;600;700&display=swap"
      />

      <section
        className="min-h-screen w-full"
        style={{ background: PAPER, color: INK, fontFamily: "'Inter', sans-serif" }}
      >
        {/* Home / hero with textured backdrop */}
        <div
          id="home"
          className="relative px-5 pt-10 pb-8"
          style={{
            background: SOFT,
            backgroundImage: `radial-gradient(${INK}10 1px, transparent 1px)`,
            backgroundSize: "12px 12px",
          }}
        >
          <div className="mx-auto flex w-full max-w-[460px] flex-col items-center text-center">
            <img
              src={data.avatar}
              alt={data.name}
              width={160}
              height={160}
              loading="eager"
              className="h-36 w-36 rounded-full object-cover"
              style={{
                border: `5px solid ${PAPER}`,
                boxShadow: "0 18px 38px rgba(26,26,34,0.18)",
              }}
            />
            <h1
              className="mt-5 leading-[1.05]"
              style={{
                fontFamily: "'Cormorant Garamond', serif",
                fontWeight: 600,
                fontStyle: "italic",
                fontSize: "clamp(2.2rem, 6vw, 2.8rem)",
              }}
            >
              {data.name}
              {data.verified && (
                <span className="ml-2 align-middle text-base not-italic" style={{ color: ACCENT }}>✓</span>
              )}
            </h1>
            {data.handle && (
              <p className="mt-1 text-sm" style={{ color: "rgba(26,26,34,0.6)" }}>
                {data.handle}
              </p>
            )}
            {data.bio && (
              <p
                className="mt-3 max-w-[400px] text-[15px] leading-relaxed"
                style={{ color: "rgba(26,26,34,0.75)" }}
              >
                {data.bio}
              </p>
            )}
          </div>
        </div>

        {/* Sticky tab bar */}
        <div
          className="sticky top-0 z-30 border-y backdrop-blur"
          style={{
            background: `${PAPER}ee`,
            borderColor: RULE,
          }}
        >
          <nav className="mx-auto flex max-w-[460px] overflow-x-auto px-2">
            {TABS.map(({ id, label, Icon }) => {
              const active = activeTab === id;
              return (
                <a
                  key={id}
                  href={`#${id}`}
                  onClick={(e) => handleTabClick(e, id)}
                  className="relative flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap px-3 py-3 text-[12px] font-semibold uppercase tracking-[0.16em] transition-colors"
                  style={{ color: active ? ACCENT : "rgba(26,26,34,0.6)" }}
                >
                  <Icon className="h-4 w-4" strokeWidth={active ? 2.2 : 1.6} />
                  <span className="hidden sm:inline">{label}</span>
                  {active && (
                    <span
                      aria-hidden
                      className="absolute inset-x-3 bottom-0 h-0.5 rounded-t-full"
                      style={{ background: ACCENT }}
                    />
                  )}
                </a>
              );
            })}
          </nav>
        </div>

        {/* Sections */}
        <div className="mx-auto w-full max-w-[460px] px-5 pb-14">
          {/* Book section */}
          {book && (
            <section id="book" className="pt-10">
              <p className="text-[10px] font-semibold uppercase tracking-[0.28em]" style={{ color: ACCENT }}>
                The Book
              </p>
              <h2
                className="mt-1 text-2xl leading-tight"
                style={{ fontFamily: "'Cormorant Garamond', serif", fontWeight: 600, color: INK }}
              >
                {book.title}
              </h2>
              <div
                className="mt-4 overflow-hidden rounded-2xl bg-white p-4"
                style={{ border: `1px solid ${RULE}` }}
              >
                <div
                  className="mx-auto mb-4 overflow-hidden rounded-md"
                  style={{
                    width: "160px",
                    height: "240px",
                    boxShadow: "0 22px 44px rgba(26,26,34,0.22)",
                  }}
                >
                  <img
                    src={book.cover}
                    alt={book.title}
                    width={500}
                    height={750}
                    loading="lazy"
                    className="h-full w-full object-cover"
                  />
                </div>
                {book.blurb && (
                  <p className="text-sm" style={{ color: "rgba(26,26,34,0.75)" }}>
                    {book.blurb}
                  </p>
                )}
                <div className="mt-3 flex items-center justify-between">
                  {book.price && (
                    <span className="text-base font-bold">{book.price}</span>
                  )}
                  <a
                    href={book.href}
                    className="inline-flex items-center gap-1 rounded-full px-4 py-2 text-sm font-semibold"
                    style={{ background: INK, color: PAPER }}
                  >
                    Buy now <ArrowUpRight className="h-3.5 w-3.5" />
                  </a>
                </div>
                {book.retailers && book.retailers.length > 0 && (
                  <div className="mt-3 flex flex-wrap gap-2">
                    {book.retailers.map((r) => (
                      <a
                        key={r.label}
                        href={r.href}
                        className="rounded-full border px-3 py-1 text-[11px] font-medium transition-colors hover:bg-[color:var(--soft)]"
                        style={{ borderColor: RULE, ["--soft" as never]: SOFT, color: INK }}
                      >
                        {r.label}
                      </a>
                    ))}
                  </div>
                )}
              </div>
            </section>
          )}

          {/* Course section */}
          {program && (
            <section id="course" className="pt-12">
              <p className="text-[10px] font-semibold uppercase tracking-[0.28em]" style={{ color: ACCENT }}>
                The Course
              </p>
              <h2
                className="mt-1 text-2xl leading-tight"
                style={{ fontFamily: "'Cormorant Garamond', serif", fontWeight: 600, color: INK }}
              >
                {program.title}
              </h2>
              <p className="mt-2 text-sm" style={{ color: "rgba(26,26,34,0.7)" }}>
                Cohort opens {program.startDate}
              </p>

              {/* Video preview placeholder */}
              <div
                className="mt-4 group relative aspect-video w-full overflow-hidden rounded-2xl"
                style={{ background: INK }}
              >
                <img
                  src={data.heroImage ?? data.avatar}
                  alt=""
                  width={800}
                  height={450}
                  loading="lazy"
                  className="h-full w-full object-cover opacity-70 transition-transform duration-500 group-hover:scale-[1.04]"
                />
                <div className="absolute inset-0 flex items-center justify-center">
                  <span
                    className="flex h-14 w-14 items-center justify-center rounded-full"
                    style={{
                      background: PAPER,
                      boxShadow: "0 12px 30px rgba(0,0,0,0.35)",
                    }}
                  >
                    <Play className="h-5 w-5 fill-current" style={{ color: INK }} />
                  </span>
                </div>
                <div
                  className="absolute bottom-0 left-0 right-0 px-3 py-2 text-[11px] font-semibold uppercase tracking-widest text-white"
                  style={{ background: "rgba(0,0,0,0.55)" }}
                >
                  60s preview
                </div>
              </div>

              {program.features && (
                <ul className="mt-4 grid gap-1.5">
                  {program.features.map((f) => (
                    <li key={f} className="flex items-start gap-2 text-sm" style={{ color: INK }}>
                      <span
                        aria-hidden
                        className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full"
                        style={{ background: ACCENT }}
                      />
                      <span>{f}</span>
                    </li>
                  ))}
                </ul>
              )}

              <a
                href={program.href}
                className="mt-4 inline-flex items-center gap-1 rounded-full px-4 py-2 text-sm font-semibold"
                style={{ background: ACCENT, color: PAPER }}
              >
                {program.ctaLabel ?? "Join the waitlist"} <ArrowUpRight className="h-3.5 w-3.5" />
              </a>
            </section>
          )}

          {/* Podcast section — show 3 episode tiles */}
          {podcast && (
            <section id="podcast" className="pt-12">
              <p className="text-[10px] font-semibold uppercase tracking-[0.28em]" style={{ color: ACCENT }}>
                The Podcast
              </p>
              <h2
                className="mt-1 text-2xl leading-tight"
                style={{ fontFamily: "'Cormorant Garamond', serif", fontWeight: 600, color: INK }}
              >
                Latest episodes
              </h2>
              <ul className="mt-4 grid gap-2">
                {[0, 1, 2].map((i) => (
                  <li key={i}>
                    <a
                      href={podcast.href}
                      className="flex items-center gap-3 rounded-2xl bg-white p-3 transition-all hover:-translate-y-0.5"
                      style={{ border: `1px solid ${RULE}` }}
                    >
                      <div
                        className="h-14 w-14 shrink-0 overflow-hidden rounded-lg"
                        style={{ background: SOFT }}
                      >
                        <img
                          src={podcast.cover}
                          alt=""
                          width={120}
                          height={120}
                          loading="lazy"
                          className="h-full w-full object-cover"
                        />
                      </div>
                      <div className="min-w-0 flex-1">
                        <p
                          className="line-clamp-1 text-sm font-semibold"
                          style={{ color: INK }}
                        >
                          {i === 0 ? podcast.title : `Episode ${40 - i} — ${["Tide tables", "Field notes", "The thaw"][i - 1] ?? "Off-week"}`}
                        </p>
                        <p className="mt-0.5 text-[11px]" style={{ color: "rgba(26,26,34,0.55)" }}>
                          {podcast.duration} · {podcast.meta ?? "Podcast"}
                        </p>
                      </div>
                      <Play className="h-4 w-4 shrink-0" style={{ color: ACCENT }} />
                    </a>
                  </li>
                ))}
              </ul>
            </section>
          )}

          {/* Shop section — 2x2 product grid */}
          <section id="shop" className="pt-12">
            <p className="text-[10px] font-semibold uppercase tracking-[0.28em]" style={{ color: ACCENT }}>
              The Shop
            </p>
            <h2
              className="mt-1 text-2xl leading-tight"
              style={{ fontFamily: "'Cormorant Garamond', serif", fontWeight: 600, color: INK }}
            >
              From the studio
            </h2>
            <div className="mt-4 grid grid-cols-2 gap-3">
              {[product, product, product, product].map((p, i) =>
                p ? (
                  <a
                    key={i}
                    href={p.href}
                    className="group overflow-hidden rounded-2xl bg-white"
                    style={{ border: `1px solid ${RULE}` }}
                  >
                    <div className="aspect-square overflow-hidden">
                      <img
                        src={p.image}
                        alt=""
                        width={400}
                        height={400}
                        loading="lazy"
                        className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
                      />
                    </div>
                    <div className="p-3">
                      <p
                        className="line-clamp-2 text-xs font-semibold leading-snug"
                        style={{ color: INK }}
                      >
                        {p.title}
                      </p>
                      <p className="mt-1 text-sm font-bold" style={{ color: INK }}>
                        {p.price}
                      </p>
                    </div>
                  </a>
                ) : null
              )}
            </div>
          </section>

          {/* Socials */}
          {data.socials && data.socials.length > 0 && (
            <div className="mt-14 flex items-center justify-center gap-4 border-t pt-8" style={{ borderColor: RULE }}>
              {data.socials.map((s, i) => {
                const Icon = ICONS[s.type] ?? Globe;
                return (
                  <a
                    key={i}
                    href={s.href}
                    aria-label={s.type}
                    className="rounded-full p-2 transition-colors"
                    style={{ color: "rgba(26,26,34,0.55)" }}
                  >
                    <Icon className="h-4 w-4" strokeWidth={1.6} />
                  </a>
                );
              })}
            </div>
          )}
        </div>
      </section>
    </>
  );
}
Claude Code Instructions

CLI Install

npx innovations add portrait-stack

Where to use it

A 'mini-website' link-in-bio page with a sticky tab bar that scrolls between sections. Pass a typed 'data' prop (LinksInBioData from src/registry/links-in-bio/types.ts). Provide: - richBlocks — opt into book / group-program (used as 'course') / podcast-episode / featured-product blocks - The shop section duplicates the single featured-product four times for the 2x2 grid; for production, replace this with your own array of products In Astro: import LinksInBioPortraitStack from '../components/innovations/links-in-bio/portrait-stack'; <LinksInBioPortraitStack client:load data={myProfile} /> In Next.js: import LinksInBioPortraitStack from '@/components/innovations/links-in-bio/portrait-stack'; Best for: creators with a book + course + podcast + shop catalog who need more than a button stack but less than a full website. Tabs use IntersectionObserver to highlight the active section as you scroll.