# Profile Page

User profile page with header, tabbed content, activity timeline, and details sidebar.

## Installation

```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/profile-page.json
```

[Registry JSON](https://ui.uptoolkit.com/r/profile-page.json)

## Preview

```tsx
import Page from "app/profile/page";

export function Preview() {
  return <Page />;
}
```


## Source

### page.tsx

```tsx
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

import {
  getActivityKindLabel,
  sampleActivity,
  sampleProfile,
  sampleProjects,
  type Profile,
  type ProfileActivity,
  type ProfileProject,
} from "@/lib/profile-data";

/**
 * User profile page.
 *
 * A header with the identity and stats, tabbed content for activity and
 * projects, and a details sidebar. Everything reads from one `Profile` record,
 * so swapping `sampleProfile` for a real lookup is the only change needed.
 */
export default function Page() {
  const profile = sampleProfile;

  return (
    <main className="min-h-svh bg-muted/40">
      <div className="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-8">
        <ProfileHeaderCard profile={profile} />

        <div className="flex flex-col gap-6 lg:flex-row">
          <div className="min-w-0 flex-1">
            <Tabs defaultValue="activity" className="gap-4">
              <TabsList variant="line" className="w-full justify-start">
                <TabsTrigger value="activity">Activity</TabsTrigger>
                <TabsTrigger value="projects">Projects</TabsTrigger>
                <TabsTrigger value="about">About</TabsTrigger>
              </TabsList>

              <TabsContent value="activity">
                <ActivityTimeline activity={sampleActivity} />
              </TabsContent>

              <TabsContent value="projects">
                <ProjectList projects={sampleProjects} />
              </TabsContent>

              <TabsContent value="about">
                <Card>
                  <CardHeader>
                    <CardTitle className="text-base">About {profile.name}</CardTitle>
                  </CardHeader>
                  <CardContent className="flex flex-col gap-4">
                    <p className="m-0 text-sm leading-relaxed text-muted-foreground">
                      {profile.bio}
                    </p>
                    <Separator />
                    <div className="flex flex-wrap gap-1.5">
                      {profile.skills.map((skill) => (
                        <Badge key={skill} variant="secondary">
                          {skill}
                        </Badge>
                      ))}
                    </div>
                  </CardContent>
                </Card>
              </TabsContent>
            </Tabs>
          </div>

          <aside className="flex w-full shrink-0 flex-col gap-4 lg:w-72">
            <DetailsCard profile={profile} />
          </aside>
        </div>
      </div>
    </main>
  );
}

function ProfileHeaderCard({ profile }: { profile: Profile }) {
  return (
    <Card className="gap-0 overflow-hidden py-0">
      <div className="h-28 bg-gradient-to-r from-primary/20 via-primary/10 to-transparent" />
      <CardContent className="flex flex-col gap-4 px-4 pt-0 pb-4 sm:px-6 sm:pb-6">
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div className="flex items-end gap-4">
            <Avatar className="-mt-10 size-20 border-4 border-background">
              <AvatarImage src={profile.avatarUrl} alt="" />
              <AvatarFallback>{profile.initials}</AvatarFallback>
            </Avatar>
            <div className="flex flex-col gap-0.5 pb-1">
              <h1 className="m-0 text-xl font-semibold tracking-tight">{profile.name}</h1>
              <p className="m-0 text-sm text-muted-foreground">
                @{profile.handle} &middot; {profile.pronouns}
              </p>
            </div>
          </div>

          <div className="flex items-center gap-2 pb-1">
            <Button variant="outline" size="sm">
              Message
            </Button>
            <Button size="sm">Follow</Button>
          </div>
        </div>

        <p className="m-0 max-w-2xl text-sm text-muted-foreground">{profile.headline}</p>

        <dl className="m-0 flex flex-wrap gap-6">
          {profile.stats.map((stat) => (
            <div key={stat.label} className="flex items-baseline gap-1.5">
              <dt className="order-2 text-sm text-muted-foreground">{stat.label}</dt>
              <dd className="order-1 m-0 text-sm font-semibold">{stat.value}</dd>
            </div>
          ))}
        </dl>
      </CardContent>
    </Card>
  );
}

function ActivityTimeline({ activity }: { activity: readonly ProfileActivity[] }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-base">Recent activity</CardTitle>
        <CardDescription>The last few things that happened.</CardDescription>
      </CardHeader>
      <CardContent>
        <ol className="m-0 flex list-none flex-col gap-0 p-0">
          {activity.map((entry, index) => (
            <li key={entry.id} className="flex gap-3">
              <div className="flex flex-col items-center">
                <span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />
                {index < activity.length - 1 ? (
                  <span className="w-px flex-1 bg-border" aria-hidden />
                ) : null}
              </div>
              <div className="flex flex-col gap-1 pb-5">
                <div className="flex flex-wrap items-center gap-2">
                  <Badge variant="outline">{getActivityKindLabel(entry.kind)}</Badge>
                  <span className="text-sm font-medium">{entry.title}</span>
                  <span className="text-xs text-muted-foreground">{entry.timestamp}</span>
                </div>
                <p className="m-0 text-sm text-muted-foreground">{entry.detail}</p>
              </div>
            </li>
          ))}
        </ol>
      </CardContent>
    </Card>
  );
}

function ProjectList({ projects }: { projects: readonly ProfileProject[] }) {
  return (
    <ul className="m-0 grid list-none gap-4 p-0 sm:grid-cols-2">
      {projects.map((project) => (
        <li key={project.id}>
          <Card className="h-full gap-2">
            <CardHeader>
              <div className="flex items-start justify-between gap-2">
                <CardTitle className="text-base">{project.name}</CardTitle>
                <Badge variant={project.status === "Active" ? "secondary" : "outline"}>
                  {project.status}
                </Badge>
              </div>
              <CardDescription>{project.description}</CardDescription>
            </CardHeader>
            <CardContent>
              <p className="m-0 text-sm text-muted-foreground">{project.role}</p>
            </CardContent>
          </Card>
        </li>
      ))}
    </ul>
  );
}

function DetailsCard({ profile }: { profile: Profile }) {
  const details = [
    { label: "Location", value: profile.location },
    { label: "Website", value: profile.website },
    { label: "Joined", value: profile.joinedAt },
  ];

  return (
    <Card className="gap-3 py-4">
      <CardHeader>
        <CardTitle className="text-base">Details</CardTitle>
      </CardHeader>
      <CardContent>
        <dl className="m-0 flex flex-col gap-3">
          {details.map((detail) => (
            <div key={detail.label} className="flex flex-col gap-0.5">
              <dt className="text-xs text-muted-foreground">{detail.label}</dt>
              <dd className="m-0 text-sm">{detail.value}</dd>
            </div>
          ))}
        </dl>
      </CardContent>
    </Card>
  );
}
```


### lib/profile-data.ts

```ts
/** The person the profile page is about. */
export type Profile = {
  name: string;
  handle: string;
  headline: string;
  bio: string;
  avatarUrl: string;
  initials: string;
  location: string;
  joinedAt: string;
  website: string;
  pronouns: string;
  skills: readonly string[];
  stats: readonly ProfileStat[];
};

export type ProfileStat = {
  label: string;
  value: string;
};

export type ProfileActivity = {
  id: string;
  title: string;
  detail: string;
  timestamp: string;
  kind: "shipped" | "wrote" | "spoke" | "joined";
};

export type ProfileProject = {
  id: string;
  name: string;
  description: string;
  role: string;
  status: "Active" | "Archived";
};

export const sampleProfile: Profile = {
  name: "Rowan Ellis",
  handle: "rowan",
  headline: "Design engineer, building tools for other builders",
  bio: "I work on the seam between design systems and the runtime that renders them. Mostly TypeScript, occasionally Rust, always too many browser tabs.",
  avatarUrl: "https://github.com/shadcn.png",
  initials: "RE",
  location: "Copenhagen, DK",
  joinedAt: "March 2021",
  website: "rowan.example.com",
  pronouns: "they/them",
  skills: ["Design systems", "TypeScript", "Accessibility", "Rust", "Prototyping"],
  stats: [
    { label: "Projects", value: "18" },
    { label: "Followers", value: "2.4k" },
    { label: "Following", value: "312" },
  ],
};

export const sampleActivity: readonly ProfileActivity[] = [
  {
    id: "activity-1",
    title: "Shipped tokens v4",
    detail: "Migrated every color token to OKLCH and dropped the legacy palette.",
    timestamp: "2 days ago",
    kind: "shipped",
  },
  {
    id: "activity-2",
    title: "Wrote “Stop nesting your primitives”",
    detail: "A short case against three-deep component wrappers in design systems.",
    timestamp: "1 week ago",
    kind: "wrote",
  },
  {
    id: "activity-3",
    title: "Spoke at NordicJS",
    detail: "Thirty minutes on rendering strategies and the cost of hydration.",
    timestamp: "3 weeks ago",
    kind: "spoke",
  },
  {
    id: "activity-4",
    title: "Joined the accessibility working group",
    detail: "Helping review component RFCs against WCAG 2.2.",
    timestamp: "2 months ago",
    kind: "joined",
  },
];

export const sampleProjects: readonly ProfileProject[] = [
  {
    id: "project-1",
    name: "Palette",
    description: "A perceptual color scale generator with contrast guardrails built in.",
    role: "Maintainer",
    status: "Active",
  },
  {
    id: "project-2",
    name: "Runlist",
    description: "Task runner that reads your package scripts and renders them as a TUI.",
    role: "Contributor",
    status: "Active",
  },
  {
    id: "project-3",
    name: "Slate",
    description: "An early static site generator, kept online for the people still using it.",
    role: "Maintainer",
    status: "Archived",
  },
];

const activityKindLabels = {
  shipped: "Shipped",
  wrote: "Wrote",
  spoke: "Spoke",
  joined: "Joined",
} as const satisfies Record<ProfileActivity["kind"], string>;

export function getActivityKindLabel(kind: ProfileActivity["kind"]): string {
  return activityKindLabels[kind];
}
```



## Usage

A profile with a cover header, identity and stats, three content tabs, and a details sidebar. It
stays a server component — nothing on the page needs client state except the tabs primitive itself.

```sh
npx shadcn@latest add @_cn/profile-page
```

Installs `app/profile/page.tsx` and the `profile-data` fixtures.

### Wiring it to a real user

Move the page to a dynamic segment and resolve the profile from the route:

```tsx
export default async function Page({ params }: { params: Promise<{ handle: string }> }) {
  const { handle } = await params;
  const profile = await db.profile.findUniqueOrThrow({ where: { handle } });

  // ...pass profile, activity, and projects into the same sections
}
```

Every section reads from the one `Profile` record plus two lists, so the only thing to replace is
the three `sample*` exports in `profile-data.ts`.

### Notes

Stats render as a `<dl>` with the value ordered before the label visually but after it in source, so
screen readers announce "Projects, 18" while sighted users read the number first. The activity
timeline is an ordered list with the connector line drawn as a sibling element, which keeps the list
semantics intact.

For a federated, ActivityPub-flavored take on the same page, see
[`social-profile-page`](/pages/social-profile-page).

