a profile on X as X draws it: banner, face, names, bio, website, counts
npx shadcn add @ag/x-profileAlready installed? Preview an update
npx shadcn add @ag/x-profile --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-16
- New: a profile on X as X draws it, from its facts (
XProfileinx-profile-data): the banner at 3:1, the face standing half over it, Follow or Edit profile (own), the name with its badge, the handle, the bio with its links, the place, the website as X shows it, when they joined, following and followers (counts, left out by a drawing that does not know them).XProfileFieldis one field of X's Edit profile, for a guide that says "type this there". Facts written by hand: X has no profile read without an account.
seen by its owner: Edit profile; the website an emoji domain, shown in its own letters

what a guide shows to say: type this there
Websitehttps://xn--mq8h.to/adriangalilea
no banner (X's grey), a badge, a bio with its link, a place
Mark Twain@marktwain
The report of my death was an exaggeration. Letters at twain.example
Hartford, ConnecticutJoined November 2009
12 Following1.8M Followers
the demo above, verbatim
import { Sample } from "@/app/samples"
import type { XProfile as XProfileFacts } from "@/registry/base-nova/lib/x-profile-data"
import { XProfile, XProfileField } from "@/registry/base-nova/ui/x-profile"
// #region profiles
/** Adrian's profile as X showed it on 2026-10-16, its pictures stand-ins. Its
* website was typed spelled out (X's website field refuses the emoji) and X
* shows it in its own letters. */
const ADRIAN: XProfileFacts = {
url: "https://x.com/adrigalilea",
author: {
name: "Adrian Galilea",
handle: "@adrigalilea",
avatar: "/adriangalilea.jpg",
},
banner: "/glass-river.jpg",
bio: [],
website: {
text: "👤.to/adriangalilea",
href: "https://xn--mq8h.to/adriangalilea",
},
joined: "2010-04-01T00:00:00Z",
counts: { following: 62, followers: 54 },
}
/** A made-up profile: no banner, a badge, a bio with a link, a place. */
const TWAIN: XProfileFacts = {
url: "https://x.com",
author: {
name: "Mark Twain",
handle: "@marktwain",
avatar: "/mark-twain.png",
verified: "blue",
},
bio: [
{ text: "The report of my death was an exaggeration. Letters at " },
{ text: "twain.example", href: "https://example.com" },
],
location: "Hartford, Connecticut",
joined: "2009-11-30T00:00:00Z",
counts: { following: 12, followers: 1_840_000 },
}
// #endregion
export default function Demo() {
return (
<div className="space-y-16">
<Sample
name="own"
glance="large"
with="profiles"
title="01 · your profile"
description="seen by its owner: Edit profile; the website an emoji domain, shown in its own letters"
>
<XProfile profile={ADRIAN} own className="mx-auto max-w-md" />
</Sample>
<Sample
name="field"
title="02 · a field of Edit profile"
description="what a guide shows to say: type this there"
>
<div className="mx-auto flex max-w-md flex-col gap-3">
<XProfileField
label="Website"
value="https://xn--mq8h.to/adriangalilea"
/>
</div>
</Sample>
<Sample
name="profile"
with="profiles"
title="03 · anyone's profile"
description="no banner (X's grey), a badge, a bio with its link, a place"
>
<XProfile profile={TWAIN} className="mx-auto max-w-md" />
</Sample>
</div>
)
}the source, every file an install writes
// A PROFILE ON X, drawn from its facts (`x-profile-data`), the header X draws at
// x.com/<handle>: the banner at 3:1, the face over its foot ringed in the surface's
// colour, the profile's button at the right, the name with its badge, the handle,
// the bio, a line of what they are (where, their website, since when) and the two
// counts. The same words as a post (`tweet`): its blue for links, its badge, its
// text, so a profile and a post side by side are one X.
//
// The whole of it opens the profile (`card-link`); the website, the bio's links and
// the button are links of their own. `XProfileField` is one field of X's Edit
// profile, the label over the value as X draws it: what a guide shows to say "type
// this there". A server component; only the card's click handler runs in the
// browser.
import { CalendarDays, Link2, MapPin } from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils"
import type { XProfile as XProfileFacts } from "@/registry/base-nova/lib/x-profile-data"
import { Button } from "@/registry/base-nova/ui/button"
import { CardLink } from "@/registry/base-nova/ui/card-link"
import { Image } from "@/registry/base-nova/ui/image"
import { Panel } from "@/registry/base-nova/ui/panel"
import {
ANCHOR,
LINK,
MUTED,
Verified,
XText,
} from "@/registry/base-nova/ui/tweet"
export interface XProfileProps {
profile: XProfileFacts
/** Seen by its owner: "Edit profile" where anyone else sees "Follow". */
own?: boolean
className?: string
}
const JOINED = new Intl.DateTimeFormat("en", {
month: "long",
year: "numeric",
timeZone: "UTC",
})
const COUNT = new Intl.NumberFormat("en", { notation: "compact" })
/** The face is a share of the header's width, as X sizes it, and stands half over
* the banner: its top margin is minus half its own width, both shares of the same
* width (a margin's percentage is the container's width). */
const FACE = 0.22
export function XProfile({ profile, own, className }: XProfileProps) {
const { author } = profile
return (
<Panel
render={<CardLink href={profile.url} />}
data-slot="x-profile"
bodyClassName="gap-0 p-0"
className={cn("clip-rounded font-sans text-[15px]", className)}
>
<div
data-slot="x-profile-banner"
className="relative aspect-[3/1] bg-foreground/10"
>
{profile.banner && (
<Image src={profile.banner} alt="" fill sizes="36rem" />
)}
</div>
<div className="flex flex-col gap-3 px-4 pb-4">
<div className="flex items-start justify-between gap-3">
<Image
src={author.avatar}
alt={author.name}
data-slot="x-profile-avatar"
className={cn(
"aspect-square w-(--ag-x-face) -mt-(--ag-x-lift) object-cover ring-4 ring-card",
author.square ? "rounded-[12%]" : "rounded-full",
)}
style={
{
"--ag-x-face": `${FACE * 100}%`,
"--ag-x-lift": `${(FACE / 2) * 100}%`,
} as React.CSSProperties
}
/>
<Button
render={<a href={profile.url} {...ANCHOR} />}
variant={own ? "outline" : "default"}
className="mt-3 rounded-full font-bold"
>
{own ? "Edit profile" : "Follow"}
</Button>
</div>
<div className="flex flex-col">
<span className="flex items-center gap-1 font-extrabold text-[20px] leading-6">
<span className="truncate">{author.name}</span>
{author.verified && <Verified kind={author.verified} />}
</span>
<span className={MUTED}>{author.handle}</span>
</div>
{profile.bio.length > 0 && <XText runs={profile.bio} />}
<div
data-slot="x-profile-facts"
className={cn("flex flex-wrap gap-x-3 gap-y-1", MUTED)}
>
{profile.location && (
<span className="flex items-center gap-1">
<MapPin className="size-[1.15em]" aria-hidden="true" />
{profile.location}
</span>
)}
{profile.website && (
<span className="flex min-w-0 items-center gap-1">
<Link2 className="size-[1.15em] shrink-0" aria-hidden="true" />
<a
href={profile.website.href}
{...ANCHOR}
className={cn("truncate", LINK)}
>
{profile.website.text}
</a>
</span>
)}
{profile.joined && (
<span className="flex items-center gap-1">
<CalendarDays className="size-[1.15em]" aria-hidden="true" />
Joined {JOINED.format(new Date(profile.joined))}
</span>
)}
</div>
{profile.counts && (
<div data-slot="x-profile-counts" className="flex gap-5">
<span>
<span className="font-bold">
{COUNT.format(profile.counts.following)}
</span>{" "}
<span className={MUTED}>Following</span>
</span>
<span>
<span className="font-bold">
{COUNT.format(profile.counts.followers)}
</span>{" "}
<span className={MUTED}>Followers</span>
</span>
</div>
)}
</div>
</Panel>
)
}
/** One field of X's Edit profile: its label over its value, in X's outlined box. */
export function XProfileField({
label,
value,
className,
}: {
label: string
value: string
className?: string
}) {
return (
<div
data-slot="x-profile-field"
className={cn(
"flex min-w-0 flex-col rounded-[4px] border border-foreground/20 px-2 pt-1.5 pb-2 font-sans",
className,
)}
>
<span className={cn("text-[13px]", MUTED)}>{label}</span>
<span className="truncate text-[17px]">{value}</span>
</div>
)
}import type { TweetAuthor, TweetRun } from "@/registry/base-nova/lib/tweet-data"
// A PROFILE ON X, as facts (`ui/x-profile` draws it as X does): the header of
// x.com/<handle>, the banner, the face, the names, the bio, the line of what they
// are (where, their website, since when) and the two counts. Written by hand: X
// has no read of a profile without an account (the syndication endpoint `tweet`
// reads answers per post, with the author's name and face only).
export interface XProfile {
/** The profile's page, x.com/<handle>. */
url: string
/** The name, the handle, the face and its badge: a post's author's (`tweet`). */
author: TweetAuthor
/** The banner, 3:1 as X crops it; none, X's grey. */
banner?: string
/** The bio as runs, its mentions and links each with what X shows for it. */
bio: TweetRun[]
location?: string
/** The website as X shows it (`text`: no scheme, an IDN in its own letters,
* `👤.to/you`) and the address it opens. */
website?: { text: string; href: string }
/** When the account was made, ISO 8601: X says the month and year. */
joined?: string
/** The counts, both or neither: a drawing that does not know them (a guide
* showing a person their own profile) leaves the row out, never a made-up 0. */
counts?: { following: number; followers: number }
}
