Creator Tiles
Photo hero with a fade to paper, circular avatar overlap, then a 2-column bento where every tile is visual: live-now strip with pulsing red dot, full-width YouTube preview tile, podcast cover tile, featured product tile, gradient freebie email-grab card, plus plain link tiles for the rest.
Preview
Source
tsx
"use client";
import { useState } from "react";
import {
Instagram,
Twitter,
Youtube,
Mail,
Globe,
ShoppingBag,
PlayCircle,
Linkedin,
Music2,
ArrowUpRight,
Play,
Radio,
Headphones,
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 = "#0e0e12";
const PAPER = "#f6f3ed";
const ACCENT = "#5b3eff";
const ACCENT2 = "#ff5fa3";
const PILL = "#1c1c22";
function tileBase(extra = "") {
return `relative overflow-hidden rounded-2xl border transition-all hover:-translate-y-0.5 ${extra}`;
}
function YouTubeTile({
thumb,
caption,
href,
}: {
thumb: string;
caption: string;
href: string;
}) {
return (
<a
href={href}
className={tileBase("col-span-2 bg-black text-white")}
style={{ borderColor: "rgba(0,0,0,0)" }}
>
<img
src={thumb}
alt=""
width={800}
height={500}
loading="lazy"
className="aspect-[16/9] w-full object-cover transition-transform duration-500 hover:scale-[1.04]"
/>
<div
aria-hidden
className="absolute inset-0"
style={{
background:
"linear-gradient(180deg, rgba(0,0,0,0) 50%, rgba(0,0,0,0.85) 100%)",
}}
/>
<div className="absolute left-3 top-3 flex items-center gap-1.5 rounded-full bg-red-600 px-2 py-1 text-[10px] font-bold uppercase tracking-wider">
<Play className="h-3 w-3 fill-white" /> YouTube
</div>
<div className="absolute inset-x-3 bottom-3">
<p className="line-clamp-2 text-sm font-semibold leading-snug">{caption}</p>
</div>
</a>
);
}
function PodcastTile({ block }: { block: Extract<RichBlock, { kind: "podcast-episode" }> }) {
return (
<a
href={block.href}
className={tileBase("bg-white p-3")}
style={{ borderColor: "rgba(14,14,18,0.06)" }}
>
<div className="overflow-hidden rounded-xl">
<img
src={block.cover}
alt=""
width={400}
height={400}
loading="lazy"
className="aspect-square w-full object-cover"
/>
</div>
<p className="mt-2 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-[0.2em]" style={{ color: ACCENT }}>
<Headphones className="h-3 w-3" /> Podcast · {block.duration}
</p>
<p
className="mt-1 line-clamp-2 text-sm font-semibold leading-snug"
style={{ color: INK }}
>
{block.title}
</p>
</a>
);
}
function FeaturedProductTile({
block,
}: {
block: Extract<RichBlock, { kind: "featured-product" }>;
}) {
return (
<a
href={block.href}
className={tileBase("bg-white")}
style={{ borderColor: "rgba(14,14,18,0.06)" }}
>
<div className="overflow-hidden">
<img
src={block.image}
alt=""
width={500}
height={500}
loading="lazy"
className="aspect-[4/5] w-full object-cover transition-transform duration-500 hover:scale-[1.03]"
/>
</div>
<div className="p-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.2em]" style={{ color: ACCENT2 }}>
Shop
</p>
<p
className="mt-1 line-clamp-2 text-sm font-semibold leading-snug"
style={{ color: INK }}
>
{block.title}
</p>
<p className="mt-1 text-sm font-bold" style={{ color: INK }}>
{block.price}
</p>
</div>
</a>
);
}
function FreebieTile({ block }: { block: Extract<RichBlock, { kind: "freebie" }> }) {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
return (
<div
className={tileBase("col-span-2 p-4")}
style={{
background: `linear-gradient(135deg, ${ACCENT} 0%, ${ACCENT2} 100%)`,
borderColor: "rgba(0,0,0,0)",
color: PAPER,
}}
>
<p className="text-[10px] font-bold uppercase tracking-[0.25em]">Free</p>
<p className="mt-1.5 text-base font-bold leading-snug">{block.title}</p>
{submitted ? (
<p className="mt-3 rounded-lg bg-white/15 px-3 py-2.5 text-sm">
✓ Sent — check your inbox.
</p>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
if (email) setSubmitted(true);
}}
className="mt-3 flex gap-2"
>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={block.placeholder ?? "Email"}
className="min-w-0 flex-1 rounded-lg bg-white/15 px-3 py-2 text-sm text-white placeholder:text-white/60 focus:outline-none focus:ring-2 focus:ring-white/40"
/>
<button
type="submit"
className="shrink-0 rounded-lg bg-white px-3 py-2 text-xs font-bold text-[color:var(--ink)]"
style={{ ["--ink" as never]: INK }}
>
{block.cta ?? "Send"}
</button>
</form>
)}
</div>
);
}
function LiveNowTile({ block }: { block: Extract<RichBlock, { kind: "live-now" }> }) {
return (
<a
href={block.href}
className={tileBase("col-span-2 flex items-center gap-3 px-4 py-4")}
style={{ background: INK, color: PAPER, borderColor: "rgba(0,0,0,0)" }}
>
<span className="relative flex h-3 w-3 shrink-0">
<span
aria-hidden
className="absolute inset-0 animate-ping rounded-full"
style={{ background: "#ff3b3b", opacity: 0.6 }}
/>
<span className="relative inline-block h-3 w-3 rounded-full" style={{ background: "#ff3b3b" }} />
</span>
<Radio className="h-4 w-4" strokeWidth={1.6} />
<span className="flex-1 text-sm font-semibold">{block.label}</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider">
{block.platform}
</span>
<ArrowUpRight className="h-4 w-4" />
</a>
);
}
function PlainLinkTile({ link }: { link: { label: string; href: string; icon?: LinkIcon; badge?: string } }) {
const Icon = link.icon ? ICONS[link.icon] : ArrowUpRight;
return (
<a
href={link.href}
className={tileBase("flex items-start gap-2 bg-white p-4")}
style={{ borderColor: "rgba(14,14,18,0.06)" }}
>
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
style={{ background: PAPER, color: INK }}
>
<Icon className="h-4 w-4" strokeWidth={1.6} />
</span>
<div className="min-w-0 flex-1">
<p
className="line-clamp-2 text-sm font-semibold leading-snug"
style={{ color: INK }}
>
{link.label}
</p>
{link.badge && (
<span
className="mt-1.5 inline-block rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider"
style={{ background: PILL, color: PAPER }}
>
{link.badge}
</span>
)}
</div>
</a>
);
}
export interface LinksInBioCreatorTilesProps {
data?: LinksInBioData;
}
export default function LinksInBioCreatorTiles({
data = defaultData,
}: LinksInBioCreatorTilesProps) {
const heroImage = data.heroImage ?? data.avatar;
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"
);
const freebie = data.richBlocks?.find(
(b): b is Extract<RichBlock, { kind: "freebie" }> => b.kind === "freebie"
);
const live = data.richBlocks?.find(
(b): b is Extract<RichBlock, { kind: "live-now" }> => b.kind === "live-now"
);
const youtubeLink =
data.links.find((l) => l.icon === "youtube") ?? data.links[0];
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
/>
<section
className="min-h-screen w-full pb-12"
style={{ background: PAPER, color: INK, fontFamily: "'Inter', sans-serif" }}
>
{/* Hero */}
<div className="relative h-60 w-full overflow-hidden">
<img
src={heroImage}
alt=""
width={1600}
height={800}
loading="eager"
className="h-full w-full object-cover"
/>
<div
aria-hidden
className="absolute inset-0"
style={{
background: `linear-gradient(180deg, rgba(14,14,18,0) 30%, ${PAPER} 100%)`,
}}
/>
</div>
{/* Avatar overlap + identity */}
<div className="relative mx-auto -mt-14 w-full max-w-[460px] px-4">
<div className="flex flex-col items-center text-center">
<img
src={data.avatar}
alt={data.name}
width={120}
height={120}
loading="eager"
className="h-28 w-28 rounded-full object-cover"
style={{ border: `4px solid ${PAPER}`, boxShadow: "0 14px 32px rgba(14,14,18,0.18)" }}
/>
<h1
className="mt-3 text-[1.7rem] font-extrabold tracking-tight"
style={{ color: INK }}
>
{data.name}
{data.verified && (
<span
className="ml-1.5 inline-block translate-y-[-2px] text-base"
style={{ color: ACCENT }}
>
✓
</span>
)}
</h1>
{data.handle && (
<p className="text-sm" style={{ color: "rgba(14,14,18,0.55)" }}>
{data.handle}
</p>
)}
{data.bio && (
<p
className="mt-2 max-w-[360px] text-[14px] leading-relaxed"
style={{ color: "rgba(14,14,18,0.7)" }}
>
{data.bio}
</p>
)}
</div>
{/* Bento grid */}
<div className="mt-6 grid grid-cols-2 gap-3">
{live && <LiveNowTile block={live} />}
<YouTubeTile
thumb="https://images.unsplash.com/photo-1492724441997-5dc865305da7?w=800&h=500&fit=crop&q=80"
caption={youtubeLink?.label ?? "Latest YouTube video"}
href={youtubeLink?.href ?? "#"}
/>
{podcast && <PodcastTile block={podcast} />}
{product && <FeaturedProductTile block={product} />}
{freebie && <FreebieTile block={freebie} />}
{data.links
.filter((l) => l.icon !== "youtube")
.slice(0, 4)
.map((link) => (
<PlainLinkTile key={link.label} link={link} />
))}
</div>
{/* Socials */}
{data.socials && data.socials.length > 0 && (
<div className="mt-7 flex 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-10 w-10 items-center justify-center rounded-full bg-white transition-transform hover:-translate-y-0.5"
style={{
border: "1px solid rgba(14,14,18,0.08)",
color: INK,
}}
>
<Icon className="h-4 w-4" strokeWidth={1.6} />
</a>
);
})}
</div>
)}
<p
className="mt-7 text-center text-[10px] uppercase tracking-[0.28em]"
style={{ color: "rgba(14,14,18,0.45)" }}
>
Made with care
</p>
</div>
</section>
</>
);
} Claude Code Instructions
CLI Install
npx innovations add creator-tilesWhere to use it
A bento-style link-in-bio page where every tile feels distinct (no monolithic button stack). Theme-neutral palette (paper / ink with purple+pink accent gradient).
Pass a typed 'data' prop (LinksInBioData from src/registry/links-in-bio/types.ts). Provide:
- heroImage — wide image used as the top hero
- richBlocks — opt into podcast-episode / featured-product / freebie / live-now tiles
- A link with icon: 'youtube' is auto-promoted to the YouTube preview tile
In Astro:
import LinksInBioCreatorTiles from '../components/innovations/links-in-bio/creator-tiles';
<LinksInBioCreatorTiles client:load data={myProfile} />
In Next.js:
import LinksInBioCreatorTiles from '@/components/innovations/links-in-bio/creator-tiles';
Best for: full-stack creators streaming multiple platforms (YouTube + podcast + Twitch + shop). Replace the placeholder YouTube thumb with your actual video thumbnail before going live.