Link in bio
One page for everything a person links to. Links, products, dates, a newsletter and a share button, as a column, a bento, tabs, a countdown or a split with a portrait.
One of 3 components in Personal brand
Variants
Details
What Link in bio brings along, read from the registry and the source.
- Category
- Personal brand
- Area
- Industries
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/link-in-bio.tsx
- npm packages
- None
- Languages
- English and German, 26 texts of its own
- Studio
- All 5 variants editable
- In collections
- Creator page
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The LinkInBioProps interface from components/ui/link-in-bio.tsx, with the comments from the code.
variantoptionalLinkInBioVariantstacktilestabsdatessplit
profilerequiredBioProfilelinksoptionalBioLink[]socialsoptionalBioSocial[]productsoptionalBioProduct[]eventsoptionalBioEvent[]groupsoptionalBioGroup[]split: links in groups
videooptional{ title: string; length?: string; href: string; media?: React.ReactNode }tiles: the video tile
newsletteroptional{ title: string; text?: string; count?: string }tiles: the newsletter tile
shareUrloptionalstringThe address of this page, copied by the share button
currencyoptionalstringonSubscribeoptional(email: string) => void
Built-in texts
Link in bio writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 26 texts
| Key | English | German |
|---|---|---|
| share | English Copy link to this page | German Link zu dieser Seite kopieren |
| copied | English Link copied | German Link kopiert |
| links | English Links | German Links |
| shop | English Shop | German Shop |
| dates | English Dates | German Termine |
| sections | English Sections | German Bereiche |
| next | English Next date | German Nächster Termin |
| in | English Starts in | German Beginnt in |
| days | English days | German Tage |
| hours | English hrs | German Std. |
| minutes | English min | German Min. |
| seconds | English sec | German Sek. |
| remind | English Remind me | German Erinnere mich |
| reminded | English Reminder set | German Erinnerung gesetzt |
| seats | English {n} seats left | German Noch {n} Plätze |
| join | English Save my seat | German Platz sichern |
| get | English Get it | German Holen |
| watch | English Watch now | German Jetzt ansehen |
| English Email address | German E-Mail-Adresse | |
| emailPh | English you@example.com | German du@beispiel.de |
| subscribe | English Subscribe | German Abonnieren |
| subscribed | English You are in. The next letter comes on Sunday. | German Du bist dabei. Der nächste Brief kommt am Sonntag. |
| invalid | English Please enter an email address like name@example.com. | German Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| socials | English Elsewhere | German Woanders |
| later | English Later dates | German Weitere Termine |
| live | English Happening now | German Läuft gerade |
Accessibility
Read from the source of the component.
- Link in bio handles the keys Arrow left and Arrow right itself.
- Sets aria-controls, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-live, aria-pressed and aria-selected, so screen readers announce purpose and state.
- Uses the roles status, tab, tablist, tabpanel and timer.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 9 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 885 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useSpotlight, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Parallax, Stagger, Magnetic } from '@/components/ui/motion';
import { Section } from '@/components/ui/section';
/*
* One page that holds everything a person links to: the latest work, the
* products, the next dates, a way to subscribe. Built for creators,
* coaches, artists and anyone with one link in a profile.
*
* stack the classic column, a featured link with a picture, the rest as rows, share with a copied check
* tiles a bento of tiles, video, newsletter with its own form, products and the next date
* tabs links, shop and dates behind one switch with a gliding marker
* dates the next date with a live countdown, the others as a list with a reminder each
* split a large portrait that stays while the grouped links scroll beside it
*/
export type LinkInBioVariant = 'stack' | 'tiles' | 'tabs' | 'dates' | 'split';
export interface BioProfile {
name: string;
/** e.g. @mira.draws or a short role */
handle?: string;
bio?: string;
location?: string;
avatar?: React.ReactNode;
/** A wide picture behind the head, used by split and tiles */
cover?: React.ReactNode;
/** A short status line with a live dot, e.g. "Booking for March" */
status?: string;
}
export interface BioLink {
id: string;
label: string;
description?: string;
href: string;
icon?: IconName;
/** A short marker like "New" */
badge?: string;
/** A picture for the featured link (the first one in stack) */
image?: React.ReactNode;
}
export interface BioSocial {
label: string;
icon: IconName;
href: string;
}
export interface BioProduct {
id: string;
title: string;
/** e.g. "Preset pack" or "Ebook, 64 pages" */
kind?: string;
price: number;
/** A struck through price before */
was?: number;
href: string;
image?: React.ReactNode;
}
export interface BioEvent {
id: string;
title: string;
/** ISO date and time, e.g. 2026-11-14T19:00 (local time of the event) */
start: string;
place?: string;
/** e.g. "Live session" or "Workshop" */
kind?: string;
href: string;
/** Seats left, shown when low */
seats?: number;
}
export interface BioGroup {
title: string;
links: BioLink[];
}
export interface LinkInBioProps {
variant?: LinkInBioVariant;
profile: BioProfile;
links?: BioLink[];
socials?: BioSocial[];
products?: BioProduct[];
events?: BioEvent[];
/** split: links in groups */
groups?: BioGroup[];
/** tiles: the video tile */
video?: { title: string; length?: string; href: string; media?: React.ReactNode };
/** tiles: the newsletter tile */
newsletter?: { title: string; text?: string; count?: string };
/** The address of this page, copied by the share button */
shareUrl?: string;
currency?: string;
onSubscribe?: (email: string) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
share: 'Copy link to this page',
copied: 'Link copied',
links: 'Links',
shop: 'Shop',
dates: 'Dates',
sections: 'Sections',
next: 'Next date',
in: 'Starts in',
days: 'days',
hours: 'hrs',
minutes: 'min',
seconds: 'sec',
remind: 'Remind me',
reminded: 'Reminder set',
seats: '{n} seats left',
join: 'Save my seat',
get: 'Get it',
watch: 'Watch now',
email: 'Email address',
emailPh: 'you@example.com',
subscribe: 'Subscribe',
subscribed: 'You are in. The next letter comes on Sunday.',
invalid: 'Please enter an email address like name@example.com.',
socials: 'Elsewhere',
later: 'Later dates',
live: 'Happening now',
},
de: {
share: 'Link zu dieser Seite kopieren',
copied: 'Link kopiert',
links: 'Links',
shop: 'Shop',
dates: 'Termine',
sections: 'Bereiche',
next: 'Nächster Termin',
in: 'Beginnt in',
days: 'Tage',
hours: 'Std.',
minutes: 'Min.',
seconds: 'Sek.',
remind: 'Erinnere mich',
reminded: 'Erinnerung gesetzt',
seats: 'Noch {n} Plätze',
join: 'Platz sichern',
get: 'Holen',
watch: 'Jetzt ansehen',
email: 'E-Mail-Adresse',
emailPh: 'du@beispiel.de',
subscribe: 'Abonnieren',
subscribed: 'Du bist dabei. Der nächste Brief kommt am Sonntag.',
invalid: 'Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.',
socials: 'Woanders',
later: 'Weitere Termine',
live: 'Läuft gerade',
},
};
type Texts = typeof TEXTS.en;
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';
const fit = '[&>*]:size-full [&_img]:object-cover';
function money(value: number, currency: string, locale: Locale) {
const whole = Number.isInteger(value);
return new Intl.NumberFormat(TAG[locale], { style: 'currency', currency, minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2 }).format(value);
}
/** Dates are read as given, without the time zone of the visitor, so server and browser agree */
function parts(iso: string) {
const [d, t = '00:00'] = iso.split('T');
const [y, m, day] = d.split('-').map(Number);
const [h, min] = t.split(':').map(Number);
return new Date(Date.UTC(y, m - 1, day, h, min));
}
const fmt = (iso: string, locale: Locale, o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(TAG[locale], { timeZone: 'UTC', ...o }).format(parts(iso));
const MOTION_CSS = `
@keyframes kitbio-in{from{opacity:0;transform:translateY(8px)}}
@keyframes kitbio-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitbio-pop{0%{transform:scale(.4);opacity:0}60%{transform:scale(1.15);opacity:1}100%{transform:scale(1)}}
@keyframes kitbio-ring{to{transform:rotate(360deg)}}
@keyframes kitbio-tick{from{transform:translateY(-30%);opacity:.35}}
@media (prefers-reduced-motion:reduce){[class*="kitbio-"],.kitbio-anim{animation:none!important}}`;
function BioMotion() {
return (
<style href="kitbio-motion" precedence="default">
{MOTION_CSS}
</style>
);
}
/** Copies the page address and shows a check for a moment */
function ShareButton({ url, t, className }: { url?: string; t: Texts; className?: string }) {
const [done, setDone] = useState(false);
const timer = useRef(0);
useEffect(() => () => window.clearTimeout(timer.current), []);
const copy = async () => {
const href = url ?? (typeof window !== 'undefined' ? window.location.href : '');
try {
await navigator.clipboard?.writeText(href);
} catch {
/* the check still confirms the press, the address is shown in the tooltip */
}
setDone(true);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setDone(false), 1800);
};
return (
<button type="button" onClick={copy} aria-label={done ? t.copied : t.share} title={url} className={cn('relative inline-flex size-11 shrink-0 items-center justify-center rounded-full border border-border bg-card/80 text-foreground shadow-[var(--highlight),var(--shadow-sm)] backdrop-blur transition-[transform,background-color] duration-300 ease-[var(--ease-kit)] hover:bg-accent active:scale-95', focusRing, className)}>
<span className={cn('absolute inset-0 flex items-center justify-center transition-[opacity,transform] duration-300', done ? 'scale-50 opacity-0' : 'opacity-100')}>
<Icon name="share" size={18} />
</span>
<span className={cn('absolute inset-0 flex items-center justify-center text-success transition-[opacity,transform] duration-300', done ? 'opacity-100' : 'scale-50 opacity-0')}>
<Icon name="check" size={18} />
</span>
<span className="sr-only" aria-live="polite">
{done ? t.copied : ''}
</span>
</button>
);
}
/** Portrait in a ring that turns slowly once, with an optional live status */
function Head({ profile, size = 'md', align = 'center' }: { profile: BioProfile; size?: 'md' | 'lg'; align?: 'center' | 'left' }) {
const center = align === 'center';
return (
<div className={cn('flex flex-col', center ? 'items-center text-center' : 'items-start text-left')}>
<div className="relative">
<span aria-hidden className="kitbio-anim absolute -inset-[3px] rounded-full bg-[conic-gradient(from_0deg,var(--primary),color-mix(in_oklab,var(--primary)_20%,transparent),var(--primary))] opacity-80 [animation:kitbio-ring_14s_linear_infinite]" />
<div data-clip-ok className={cn('relative overflow-hidden rounded-full bg-muted ring-[3px] ring-background', size === 'lg' ? 'size-24' : 'size-[84px]', fit)}>{profile.avatar}</div>
</div>
<h3 className={cn('mt-4 font-semibold tracking-[-0.02em] text-foreground', size === 'lg' ? 'text-[26px]' : 'text-[21px]')}>{profile.name}</h3>
{profile.handle && <p className="mt-0.5 text-[14px] text-muted-foreground">{profile.handle}</p>}
{profile.bio && <p className={cn('mt-3 max-w-[30rem] text-pretty text-[15px] leading-relaxed text-foreground/85', center && 'text-balance')}>{profile.bio}</p>}
{(profile.status || profile.location) && (
<div className={cn('mt-4 flex flex-wrap items-center gap-2', center && 'justify-center')}>
{profile.status && (
<span className="inline-flex min-h-8 items-center gap-2 rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-3 text-[13px] font-medium text-success-foreground">
<span className="relative flex size-2">
<span aria-hidden className="absolute inset-0 animate-ping rounded-full bg-success opacity-60 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-success" />
</span>
{profile.status}
</span>
)}
{profile.location && (
<span className="inline-flex min-h-8 items-center gap-1.5 rounded-full border border-border px-3 text-[13px] text-muted-foreground">
<Icon name="map-pin" size={14} />
{profile.location}
</span>
)}
</div>
)}
</div>
);
}
function Socials({ socials = [], t, align = 'center' }: { socials?: BioSocial[]; t: Texts; align?: 'center' | 'left' }) {
if (!socials.length) return null;
return (
<ul aria-label={t.socials} className={cn('flex flex-wrap gap-2', align === 'center' && 'justify-center')}>
{socials.map((s) => (
<li key={s.label}>
<a href={s.href} aria-label={s.label} title={s.label} className={cn('inline-flex size-11 items-center justify-center rounded-full border border-border text-muted-foreground transition-[color,background-color,transform] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:bg-accent hover:text-foreground', focusRing)}>
<Icon name={s.icon} size={18} />
</a>
</li>
))}
</ul>
);
}
/** One link as a full width row. Arrow slides, light follows the mouse. */
function LinkRow({ link, className }: { link: BioLink; className?: string }) {
const spot = useSpotlight<HTMLAnchorElement>();
return (
<a href={link.href} onPointerMove={spot} className={cn('kit-spotlight group relative flex min-h-[60px] items-center gap-3.5 rounded-xl border border-border bg-card px-4 py-3 shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow,border-color] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)]', focusRing, className)}>
{link.icon && (
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground transition-colors group-hover:bg-[color-mix(in_oklab,var(--primary)_14%,var(--muted))] group-hover:text-primary">
<Icon name={link.icon} size={18} />
</span>
)}
<span className="min-w-0 flex-1">
<span className="flex items-start gap-2">
<span className="line-clamp-2 text-[15px] font-medium leading-snug text-foreground">{link.label}</span>
{link.badge && <span className="mt-px shrink-0 rounded-full bg-primary px-2 py-0.5 text-[11px] font-semibold text-primary-foreground">{link.badge}</span>}
</span>
{link.description && <span className="mt-0.5 block truncate text-[13px] text-muted-foreground">{link.description}</span>}
</span>
<Icon name="arrow-up-right" size={17} className="text-muted-foreground transition-transform duration-300 ease-[var(--ease-kit)] group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-foreground" />
</a>
);
}
/** The featured link, a picture that zooms a little on hover */
function FeaturedLink({ link }: { link: BioLink }) {
return (
<a href={link.href} className={cn('group block overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-md)]', focusRing)}>
{link.image && (
<div data-clip-ok className="relative aspect-[16/8] overflow-hidden bg-muted">
<div className={cn('absolute inset-0 transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.04] motion-reduce:transition-none', fit)}>{link.image}</div>
{link.badge && <span className="absolute left-3 top-3 rounded-full bg-background/85 px-2.5 py-1 text-[12px] font-semibold text-foreground backdrop-blur">{link.badge}</span>}
</div>
)}
<div className="flex items-center gap-3 px-4 py-3.5">
<span className="min-w-0 flex-1">
<span className="block text-[15.5px] font-semibold text-foreground">{link.label}</span>
{link.description && <span className="mt-0.5 block text-[13px] text-muted-foreground">{link.description}</span>}
</span>
<span className="flex size-9 items-center justify-center rounded-full bg-primary text-primary-foreground transition-transform duration-300 ease-[var(--ease-kit)] group-hover:translate-x-0.5">
<Icon name="arrow-right" size={16} />
</span>
</div>
</a>
);
}
/** Live countdown, computed only in the browser. The server shows the date. */
function useCountdown(iso?: string) {
const [left, setLeft] = useState<number | null>(null);
useEffect(() => {
if (!iso) return;
// The event time is read as local time of the visitor
const [d, tm = '00:00'] = iso.split('T');
const [y, m, day] = d.split('-').map(Number);
const [h, min] = tm.split(':').map(Number);
const target = new Date(y, m - 1, day, h, min).getTime();
const tick = () => setLeft(Math.max(0, target - Date.now()));
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [iso]);
return left;
}
function Countdown({ iso, t }: { iso: string; t: Texts }) {
const left = useCountdown(iso);
const locale = useLocale();
const units = left === null ? null : [Math.floor(left / 864e5), Math.floor(left / 36e5) % 24, Math.floor(left / 6e4) % 60, Math.floor(left / 1e3) % 60];
const names = [t.days, t.hours, t.minutes, t.seconds];
if (left === 0)
return (
<p role="status" className="flex min-h-[76px] items-center justify-center gap-2 rounded-xl border border-border bg-card text-[15px] font-semibold text-foreground">
<span className="size-2 rounded-full bg-destructive" />
{t.live}
</p>
);
return (
<div className="grid grid-cols-4 gap-px overflow-hidden rounded-xl border border-border bg-border" role="timer" aria-label={`${t.in} ${fmt(iso, locale, { dateStyle: 'long' })}`}>
{names.map((n, i) => (
<div key={n} className="bg-card px-2 py-3 text-center">
<span className="block overflow-hidden text-[26px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground sm:text-[30px]">
{units ? (
<span key={units[i]} className={cn('inline-block', i === 3 && '[animation:kitbio-tick_.35s_var(--ease-kit)]')}>
{String(units[i]).padStart(2, '0')}
</span>
) : (
<span className="text-muted-foreground">··</span>
)}
</span>
<span className="mt-1.5 block text-[12px] text-muted-foreground">{n}</span>
</div>
))}
</div>
);
}
function DateBadge({ iso, className }: { iso: string; className?: string }) {
const locale = useLocale();
return (
<span className={cn('flex w-14 shrink-0 flex-col items-center justify-center rounded-lg border border-border bg-surface py-1.5 leading-none', className)}>
<span className="text-[11px] font-semibold uppercase tracking-[0.06em] text-primary">{fmt(iso, locale, { month: 'short' }).replace('.', '')}</span>
<span className="mt-1 text-[20px] font-semibold tabular-nums text-foreground">{fmt(iso, locale, { day: 'numeric' }).replace('.', '')}</span>
</span>
);
}
function RemindButton({ t, title }: { t: Texts; title: string }) {
const [on, setOn] = useState(false);
return (
<button type="button" aria-pressed={on} aria-label={`${on ? t.reminded : t.remind}, ${title}`} onClick={() => setOn((v) => !v)} className={cn('inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-[background-color,border-color,color] duration-300', focusRing, on ? 'border-transparent bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-foreground' : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground')}>
<span key={String(on)} className={cn('inline-flex', on && '[animation:kitbio-pop_.45s_var(--ease-kit)]')}>
<Icon name={on ? 'check' : 'bell'} size={15} className={on ? 'text-primary' : undefined} />
</span>
<span className="max-sm:sr-only">{on ? t.reminded : t.remind}</span>
</button>
);
}
function EventRow({ e, t }: { e: BioEvent; t: Texts }) {
const locale = useLocale();
return (
<li className="flex items-center gap-3.5 py-3.5">
<DateBadge iso={e.start} />
<div className="min-w-0 flex-1">
<a href={e.href} className={cn('-my-3 block rounded-sm py-3 text-[15px] font-medium text-foreground hover:underline hover:underline-offset-4', focusRing)}>
{e.title}
</a>
<p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">
{[e.kind, fmt(e.start, locale, { weekday: 'short', hour: 'numeric', minute: '2-digit' }), e.place].filter(Boolean).join(' · ')}
</p>
{e.seats !== undefined && e.seats <= 10 && <p className="mt-1 text-[12.5px] font-medium text-warning-foreground">{fill(t.seats, { n: e.seats })}</p>}
</div>
<RemindButton t={t} title={e.title} />
</li>
);
}
function ProductCard({ p, currency, t, className }: { p: BioProduct; currency: string; t: Texts; className?: string }) {
const locale = useLocale();
return (
<a href={p.href} className={cn('group flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-md)]', focusRing, className)}>
<div data-clip-ok className="relative aspect-square overflow-hidden bg-muted">
<div className={cn('absolute inset-0 transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-105 motion-reduce:transition-none', fit)}>{p.image}</div>
</div>
<div className="flex flex-1 flex-col p-3.5">
{p.kind && <span className="text-[12px] text-muted-foreground">{p.kind}</span>}
<span className="mt-0.5 line-clamp-2 text-[14.5px] font-medium leading-snug text-foreground">{p.title}</span>
<span className="mt-auto flex items-baseline gap-2 pt-2.5">
<span className="text-[15px] font-semibold tabular-nums text-foreground">{money(p.price, currency, locale)}</span>
{p.was && <s className="text-[13px] tabular-nums text-muted-foreground">{money(p.was, currency, locale)}</s>}
<span className="ml-auto inline-flex items-center gap-1 text-[13px] font-medium text-primary">
{t.get}
<Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover:translate-x-0.5" />
</span>
</span>
</div>
</a>
);
}
/** A row of choices with a marker that glides to the active one */
function Switch({ options, value, onChange, label }: { options: { id: string; label: string; count?: number }[]; value: string; onChange: (id: string) => void; label: string }) {
const wrap = useRef<HTMLDivElement>(null);
const [box, setBox] = useState<{ x: number; w: number } | null>(null);
useEffect(() => {
const el = wrap.current;
if (!el) return;
const measure = () => {
const b = el.querySelector<HTMLElement>('[aria-selected="true"]');
if (b) setBox({ x: b.offsetLeft, w: b.offsetWidth });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [value]);
const key = (e: React.KeyboardEvent) => {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
e.preventDefault();
const i = options.findIndex((o) => o.id === value);
const n = options[(i + d + options.length) % options.length];
onChange(n.id);
wrap.current?.querySelector<HTMLElement>(`[data-v="${n.id}"]`)?.focus();
};
return (
<div ref={wrap} role="tablist" aria-label={label} onKeyDown={key} className="relative flex w-full rounded-xl border border-border bg-muted p-1">
{box && <span aria-hidden className="absolute bottom-1 left-0 top-1 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] ring-1 ring-border-strong transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: box.w, transform: `translateX(${box.x}px)` }} />}
{options.map((o) => {
const on = o.id === value;
return (
<button key={o.id} id={`bio-tab-${o.id}`} type="button" role="tab" data-v={o.id} aria-selected={on} aria-controls={`bio-panel-${o.id}`} tabIndex={on ? 0 : -1} onClick={() => onChange(o.id)} className={cn('relative z-[1] inline-flex min-h-11 flex-1 items-center justify-center gap-1.5 rounded-lg px-3 text-[14px] font-medium transition-colors', focusRing, on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', on && !box && 'bg-card shadow-[var(--shadow-sm)]')}>
{o.label}
{o.count !== undefined && <span className={cn('rounded-full px-1.5 text-[11.5px] tabular-nums transition-colors', on ? 'bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-foreground' : 'bg-background/60 text-muted-foreground')}>{o.count}</span>}
</button>
);
})}
</div>
);
}
/** A soft cover behind the column, it drifts slower than the page */
function Backdrop({ cover }: { cover?: React.ReactNode }) {
if (!cover) return null;
return (
<div aria-hidden data-clip-ok className="pointer-events-none absolute left-1/2 top-[-80px] h-[540px] w-screen -translate-x-1/2 overflow-hidden [mask-image:linear-gradient(to_bottom,black_35%,transparent)]">
<Parallax speed={60} className={cn('absolute -inset-y-10 inset-x-0 opacity-40 blur-3xl saturate-150 dark:opacity-30', fit)}>
{cover}
</Parallax>
</div>
);
}
/* ------------------------------------------------------------------ */
/* stack */
/* ------------------------------------------------------------------ */
function StackView({ profile, links = [], socials, shareUrl, t }: LinkInBioProps & { t: Texts }) {
const [first, ...rest] = links;
const featured = first?.image ? first : undefined;
const rows = featured ? rest : links;
return (
<div className="relative mx-auto w-full max-w-[460px]">
<div className="absolute right-0 top-0">
<ShareButton url={shareUrl} t={t} />
</div>
<div className="kit-rise">
<Head profile={profile} />
</div>
<Stagger step={70} className="mt-8 grid grid-cols-[minmax(0,1fr)] gap-3">
{featured && <FeaturedLink link={featured} />}
{rows.map((l) => (
<LinkRow key={l.id} link={l} />
))}
</Stagger>
<div className="mt-8">
<Socials socials={socials} t={t} />
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* tiles */
/* ------------------------------------------------------------------ */
function NewsletterTile({ data, t, onSubscribe }: { data: NonNullable<LinkInBioProps['newsletter']>; t: Texts; onSubscribe?: (e: string) => void }) {
const [email, setEmail] = useState('');
const [state, setState] = useState<'idle' | 'error' | 'done'>('idle');
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return setState('error');
onSubscribe?.(email);
setState('done');
};
return (
<div className="flex h-full flex-col">
<span className="flex size-10 items-center justify-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-primary">
<Icon name="mail" size={19} />
</span>
<h4 className="mt-4 text-[17px] font-semibold tracking-[-0.01em] text-foreground">{data.title}</h4>
{data.text && <p className="mt-1.5 text-pretty text-[14px] leading-relaxed text-muted-foreground">{data.text}</p>}
{state === 'done' ? (
<p role="status" className="mt-auto flex items-start gap-2 pt-5 text-[14px] font-medium text-foreground [animation:kitbio-swap_.5s_var(--ease-kit)]">
<Icon name="check-circle" size={18} className="mt-px text-success" />
{t.subscribed}
</p>
) : (
<form onSubmit={submit} noValidate className="mt-auto pt-5">
<label htmlFor="bio-nl" className="sr-only">
{t.email}
</label>
<div className="flex gap-2">
<input id="bio-nl" type="email" value={email} onChange={(e) => { setEmail(e.target.value); if (state === 'error') setState('idle'); }} placeholder={t.emailPh} aria-invalid={state === 'error'} aria-describedby={state === 'error' ? 'bio-nl-err' : undefined} className={cn('h-11 w-0 min-w-0 flex-1 rounded-lg border bg-background px-3 text-[14px] text-foreground placeholder:text-subtle-foreground', focusRing, state === 'error' ? 'border-destructive' : 'border-border-strong')} />
<Button type="submit" size="md" className="px-4">
{t.subscribe}
</Button>
</div>
{state === 'error' && (
<p id="bio-nl-err" className="mt-2 text-[13px] text-destructive">
{t.invalid}
</p>
)}
{data.count && <p className="mt-2.5 text-[12.5px] text-muted-foreground">{data.count}</p>}
</form>
)}
</div>
);
}
function TilesView({ profile, links = [], socials, products = [], events = [], video, newsletter, shareUrl, currency = 'EUR', onSubscribe, t }: LinkInBioProps & { t: Texts }) {
const locale = useLocale();
const spot = useSpotlight<HTMLDivElement>();
const next = events[0];
const tile = 'kit-card relative overflow-hidden';
return (
<Stagger step={60} className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4">
<div className={cn(tile, 'flex flex-col p-6 sm:col-span-2 lg:row-span-2 lg:p-8')}>
<div className="flex items-start justify-between gap-4">
<Head profile={profile} size="lg" align="left" />
<ShareButton url={shareUrl} t={t} />
</div>
<div className="mt-5">
<Socials socials={socials} t={t} align="left" />
</div>
{links.length > 0 && (
<div className="mt-6 border-t border-border pt-3 lg:mt-auto lg:pt-4"><ul className="-mx-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1">
{links.map((l) => (
<li key={l.id}>
<a href={l.href} className={cn('group flex min-h-12 items-center gap-3 rounded-lg px-3 py-2.5 transition-colors hover:bg-accent', focusRing)}>
{l.icon && <Icon name={l.icon} size={17} className="text-muted-foreground transition-colors group-hover:text-primary" />}
<span className="line-clamp-2 min-w-0 flex-1 text-[14.5px] font-medium leading-snug text-foreground">{l.label}</span>
<Icon name="arrow-up-right" size={15} className="text-muted-foreground transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
</a>
</li>
))}
</ul></div>
)}
</div>
{video && (
<a href={video.href} className={cn(tile, 'group min-h-[240px] sm:col-span-2', focusRing)}>
<div data-clip-ok className={cn('absolute inset-0 bg-muted transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.03] motion-reduce:transition-none', fit)}>{video.media}</div>
<div className="absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0/0.82),rgb(0_0_0/0.4)_45%,transparent_80%)]" />
<span className="absolute left-1/2 top-1/2 flex size-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 text-black shadow-[var(--shadow-lg)] transition-transform duration-500 ease-[var(--ease-kit)] group-hover:scale-110">
<span aria-hidden className="absolute inset-0 animate-ping rounded-full bg-white/40 [animation-duration:2.4s] motion-reduce:animate-none" />
<Icon name="play" size={24} className="relative ml-1" />
</span>
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 p-5 text-white">
<span>
<span className="block text-[12.5px] font-medium text-white/80">{t.watch}</span>
<span className="mt-0.5 block text-[17px] font-semibold leading-snug">{video.title}</span>
</span>
{video.length && <span className="shrink-0 rounded-md bg-black/50 px-2 py-1 text-[12.5px] tabular-nums">{video.length}</span>}
</div>
</a>
)}
{newsletter && (
<div className={cn(tile, 'p-6')}>
<NewsletterTile data={newsletter} t={t} onSubscribe={onSubscribe} />
</div>
)}
{next && (
<div onPointerMove={spot} className={cn(tile, 'kit-spotlight flex flex-col p-6')}>
<p className="text-[12.5px] font-medium text-muted-foreground">{t.next}</p>
<div className="mt-3 flex items-center gap-3">
<DateBadge iso={next.start} />
<span className="min-w-0">
<span className="block text-[15.5px] font-semibold leading-snug text-foreground">{next.title}</span>
<span className="mt-0.5 block text-[13px] text-muted-foreground">{[next.kind, fmt(next.start, locale, { hour: 'numeric', minute: '2-digit' })].filter(Boolean).join(' · ')}</span>
</span>
</div>
{next.seats !== undefined && (
<div className="mt-5">
<div className="flex justify-between text-[12.5px]">
<span className="font-medium text-warning-foreground">{fill(t.seats, { n: next.seats })}</span>
</div>
<SeatBar left={next.seats} />
</div>
)}
<span className="mt-auto pt-5" /><Button href={next.href} variant="outline" size="sm" icon="arrow-right" className="self-start max-sm:w-full max-sm:self-stretch">
{t.join}
</Button>
</div>
)}
{products.slice(0, 4).map((p) => (
<ProductCard key={p.id} p={p} currency={currency} t={t} className="max-sm:flex-row [&>div:first-child]:max-sm:aspect-auto [&>div:first-child]:max-sm:w-28" />
))}
</Stagger>
);
}
/** How full a date is, the bar grows when it scrolls in */
function SeatBar({ left, total = 30 }: { left: number; total?: number }) {
const [ref, inView] = useInView<HTMLDivElement>();
const taken = round(Math.min(1, Math.max(0, (total - left) / total)), 3);
return (
<div ref={ref} className="mt-2 h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]" aria-hidden>
<div className="h-full origin-left rounded-full bg-warning transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? taken : 0})` }} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* tabs */
/* ------------------------------------------------------------------ */
function TabsView({ profile, links = [], socials, products = [], events = [], shareUrl, currency = 'EUR', t }: LinkInBioProps & { t: Texts }) {
const opts = [
{ id: 'links', label: t.links, count: links.length },
...(products.length ? [{ id: 'shop', label: t.shop, count: products.length }] : []),
...(events.length ? [{ id: 'dates', label: t.dates, count: events.length }] : []),
];
const [tab, setTab] = useState('links');
return (
<div className="relative mx-auto w-full max-w-[520px]">
<div className="absolute right-0 top-0">
<ShareButton url={shareUrl} t={t} />
</div>
<div className="kit-rise">
<Head profile={profile} />
<div className="mt-6">
<Socials socials={socials} t={t} />
</div>
</div>
<div className="mt-8">
<Switch label={t.sections} options={opts} value={tab} onChange={setTab} />
</div>
<div key={tab} id={`bio-panel-${tab}`} role="tabpanel" aria-labelledby={`bio-tab-${tab}`} className="mt-5 [animation:kitbio-swap_.45s_var(--ease-kit)]">
{tab === 'links' && (
<div className="grid grid-cols-[minmax(0,1fr)] gap-3">
{links.map((l, i) => (
<div key={l.id} style={{ animationDelay: `${i * 50}ms` }} className="[animation:kitbio-in_.45s_var(--ease-kit)_both]">
<LinkRow link={l} />
</div>
))}
</div>
)}
{tab === 'shop' && (
<div className="grid grid-cols-2 gap-3">
{products.map((p, i) => (
<div key={p.id} className="flex [animation:kitbio-in_.45s_var(--ease-kit)_both] [&>a]:w-full" style={{ animationDelay: `${i * 60}ms` }}>
<ProductCard p={p} currency={currency} t={t} />
</div>
))}
</div>
)}
{tab === 'dates' && (
<ul className="divide-y divide-border rounded-xl border border-border bg-card px-4">
{events.map((e) => (
<EventRow key={e.id} e={e} t={t} />
))}
</ul>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* dates */
/* ------------------------------------------------------------------ */
function DatesView({ profile, links = [], events = [], socials, shareUrl, t }: LinkInBioProps & { t: Texts }) {
const locale = useLocale();
const [next, ...later] = events;
return (
<div className="relative mx-auto w-full max-w-[520px]">
<div className="absolute right-0 top-0">
<ShareButton url={shareUrl} t={t} />
</div>
<div className="kit-rise">
<Head profile={profile} />
</div>
{next && (
<div className="kit-card mt-8 overflow-hidden kit-rise [--kit-delay:120ms]">
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3">
<span className="inline-flex items-center gap-2 text-[13px] font-medium text-foreground">
<span className="relative flex size-2">
<span aria-hidden className="absolute inset-0 animate-ping rounded-full bg-primary opacity-60 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-primary" />
</span>
{t.next}
</span>
{next.kind && <span className="text-[12.5px] text-muted-foreground">{next.kind}</span>}
</div>
<div className="p-5">
<h4 className="text-[19px] font-semibold leading-snug tracking-[-0.01em] text-foreground">{next.title}</h4>
<p className="mt-1 text-[14px] text-muted-foreground">
{fmt(next.start, locale, { weekday: 'long', day: 'numeric', month: 'long', hour: 'numeric', minute: '2-digit' })}
{next.place && ` · ${next.place}`}
</p>
<p className="mb-2.5 mt-5 text-[12.5px] font-medium text-muted-foreground">{t.in}</p>
<Countdown iso={next.start} t={t} />
<div className="mt-5 flex flex-wrap items-center gap-3">
<Magnetic className="max-sm:flex max-sm:w-full"><Button href={next.href} icon="arrow-right" className="max-sm:w-full">
{t.join}
</Button></Magnetic>
{next.seats !== undefined && <span className="text-[13px] font-medium text-warning-foreground">{fill(t.seats, { n: next.seats })}</span>}
</div>
</div>
</div>
)}
{later.length > 0 && (
<>
<p className="mb-1 mt-8 text-[13px] font-medium text-muted-foreground">{t.later}</p>
<Stagger as="ul" step={70} className="divide-y divide-border border-y border-border">
{later.map((e) => (
<EventRow key={e.id} e={e} t={t} />
))}
</Stagger>
</>
)}
{links.length > 0 && (
<Stagger step={60} className="mt-8 grid grid-cols-[minmax(0,1fr)] gap-3">
{links.map((l) => (
<LinkRow key={l.id} link={l} />
))}
</Stagger>
)}
<div className="mt-8">
<Socials socials={socials} t={t} />
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* split */
/* ------------------------------------------------------------------ */
function SplitView({ profile, groups = [], socials, shareUrl, t }: LinkInBioProps & { t: Texts }) {
return (
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:gap-16">
<div className="lg:sticky lg:top-8 lg:self-start">
<div className="kit-card relative overflow-hidden">
<div data-clip-ok className="relative aspect-[4/3] overflow-hidden bg-muted sm:aspect-[16/9] lg:aspect-[5/4]">
<Parallax speed={50} className={cn('absolute -inset-y-8 inset-x-0', fit)}>
{profile.cover ?? profile.avatar}
</Parallax>
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-[linear-gradient(to_top,rgb(0_0_0/0.6),transparent)]" />
<div className="absolute right-4 top-4">
<ShareButton url={shareUrl} t={t} />
</div>
<div className="absolute inset-x-0 bottom-0 p-5 text-white sm:p-6">
<h3 className="text-[26px] font-semibold leading-tight tracking-[-0.02em] sm:text-[30px]">{profile.name}</h3>
{profile.handle && <p className="mt-1 text-[14.5px] text-white/80">{profile.handle}</p>}
</div>
</div>
<div className="p-5 sm:p-6">
{profile.bio && <p className="text-pretty text-[15px] leading-relaxed text-foreground/85">{profile.bio}</p>}
{profile.status && (
<p className="mt-4 inline-flex min-h-8 items-center gap-2 rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-3 text-[13px] font-medium text-success-foreground">
<span className="relative flex size-2">
<span aria-hidden className="absolute inset-0 animate-ping rounded-full bg-success opacity-60 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-success" />
</span>
{profile.status}
</p>
)}
<div className="mt-5 border-t border-border pt-5">
<Socials socials={socials} t={t} align="left" />
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-10">
{groups.map((g, gi) => (
<section key={g.title} aria-labelledby={`bio-g-${gi}`}>
<div className="mb-4 flex items-baseline justify-between gap-3 border-b border-border pb-3">
<h3 id={`bio-g-${gi}`} className="text-[17px] font-semibold tracking-[-0.01em] text-foreground">
{g.title}
</h3>
<span className="text-[13px] tabular-nums text-muted-foreground">{String(g.links.length).padStart(2, '0')}</span>
</div>
<Stagger step={60} className="grid grid-cols-[minmax(0,1fr)] gap-3">
{g.links.map((l) => (l.image ? <FeaturedLink key={l.id} link={l} /> : <LinkRow key={l.id} link={l} />))}
</Stagger>
</section>
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* LinkInBio */
/* ------------------------------------------------------------------ */
/**
* A link-in-bio page for a person: profile, links, products and dates.
* stack the classic column with a featured link and share
* tiles a bento of profile, video, newsletter, next date, products and links
* tabs links, shop and dates behind a gliding switch
* dates a live countdown to the next date, reminders for the others
* split a portrait that stays while grouped links scroll beside it
*/
export function LinkInBio(props: LinkInBioProps) {
const { variant = 'stack', labels, className, profile } = props;
const t = useTexts(TEXTS, labels);
const narrow = variant === 'stack' || variant === 'tabs' || variant === 'dates';
const body = (() => {
switch (variant) {
case 'tiles':
return <TilesView {...props} t={t} />;
case 'tabs':
return <TabsView {...props} t={t} />;
case 'dates':
return <DatesView {...props} t={t} />;
case 'split':
return <SplitView {...props} t={t} />;
default:
return <StackView {...props} t={t} />;
}
})();
return (
<Section spacing="sm" className={cn('overflow-clip', className)} background={variant === 'tiles' ? 'surface' : 'default'}>
<BioMotion />
{narrow && <Backdrop cover={profile.cover} />}
<div className="relative">{body}</div>
</Section>
);
}Frequent questions
How do I install Link in bio?
With aiw add @ai-website/link-in-bio. The command puts components/ui/link-in-bio.tsx into your project, the code is yours from then on. It brings along button, icon, motion and section. It needs no further npm packages.
Which variants does Link in bio have?
5 variants, Stack, Tiles, Tabs, Dates and Split with portrait. You pick one with the variant prop, for example variant="tiles".
Can I translate the texts of Link in bio?
Yes. Link in bio carries 26 texts of its own in English and German, such as “Copy link to this page”, “Link copied” and “Links”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Link in bio?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Link in bio with aiw_components and install it with aiw_add @ai-website/link-in-bio. Name the variant in your prompt, for example variant="stack", and the assistant writes your content straight into the props.
Can I adjust Link in bio without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching LinkInBio code to copy.