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-stackWhere 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.