← ui

x-profile

ui

a profile on X as X draws it: banner, face, names, bio, website, counts

npx shadcn add @ag/x-profile
Already installed? Preview an update
npx shadcn add @ag/x-profile --dry-run

Your 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 (XProfile in x-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). XProfileField is 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.
Updated 2026-10-16 · release notes

also called twitter-profile

shelf social & links

uses tweet, button, card-link, panel, image

seen by its owner: Edit profile; the website an emoji domain, shown in its own letters

Adrian Galilea@adrigalilea
👤.to/adriangalileaJoined April 2010
62 Following54 Followers

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

x-profile.demo.tsx
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

x-profile.tsx
// 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>
  )
}
x-profile-data.ts
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 }
}