ProfileLayout
A full-page profile layout: sticky top bar with animated tabs, a fixed left rail with personal info, and a scrollable main column. The top bar gains frosted-glass styling and reveals the tab strip on scroll.
Layout zones
The HeroRail and TabStrip both stick just below the top bar. Once the page scrolls far enough, the TabStrip hides and the top bar reveals its own tab strip with a slide-in animation.
Demo
Scroll inside the preview to see the sticky topbar and tab-reveal animation. Toggle to dark mode to see the frosted-glass topbar.
Avatar image
By default the HeroRail renders a monogram derived from profile.name. Pass
profile.image to render an actual image instead — 1:1 aspect-ratio with
object-cover, falling back to the monogram if the URL is omitted:
Editable avatar
Pass profile.onAvatarChange to make the rail avatar interactive. Hovering
reveals a "change avatar" scrim and clicking opens a built-in upload sheet
(drag-and-drop, file picker, live preview, optional remove). The callback
fires with the chosen File on save, or null when the user clicked the
in-sheet "remove avatar" affordance before saving. It may return a Promise;
the sheet keeps itself open with a spinner while it resolves, and surfaces
a generic error if it rejects.
Editable name
profile.onEditClick adds a pencil button next to the name in the rail.
The pencil only materialises on group-hover of the name row, matching the
source design's restraint. The callback is fired-and-forgotten — the
component does not own any edit dialog; the caller decides what happens
next (typically a Dialog with its own validation, since profile-edit
flows tend to be app-specific):
Name accessory slot
profile.nameAccessory is a render slot anchored to the right of the
name row. The library stays content-agnostic — pass any React node
(theme-toggle pill, status badge, settings shortcut, etc.). The slot
stays visible regardless of hover so the accessory is always reachable
without opening a menu.
Controlled tabs
By default ProfileLayout manages its own active tab — pass defaultTab for
the initial value and the layout takes it from there. For deep-linking,
in-page navigation, or any case where the active tab needs to live in external
state, switch to controlled mode by passing tab + onTabChange together:
tab wins when both tab and defaultTab are present.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
profile | ProfileLayoutProfile | — | Profile data rendered in the left rail. |
tabs | ProfileLayoutTab[] | — | Tab descriptors with render functions. |
defaultTab | string | tabs[0] | Initial tab in uncontrolled mode. Ignored when tab is provided. |
tab | string | — | Controlled active tab. Pass alongside onTabChange to drive tab selection from outside. |
onTabChange | (tab: string) => void | — | Fires when a tab change is requested. Required when using tab. |
logo | ReactNode | — | Slot rendered top-left. Animates vertical position on scroll. |
actions | ReactNode | — | Slot rendered top-right (e.g. account switcher). |
scrollContainerRef | RefObject<HTMLElement> | window | Override scroll source. Pass a ref when embedding inside a bounded div. |
className | string | — | Extra classes on the root element. |
ProfileLayoutProfile
| Field | Type | Description |
|---|---|---|
name | string | Display name. Initials derive for the monogram fallback. |
handle | string | Username shown as @handle below the name. |
kind | 'user' | 'org' | Org accounts show a members section in the rail. |
image | string? | Avatar image URL. When provided, the HeroRail renders an <img> (1:1, object-cover); when omitted, falls back to a monogram. |
bio | string? | Optional bio paragraph. |
facts | ProfileLayoutFact[]? | Key–value pairs in the facts grid (label column, value fills). |
members | ProfileLayoutMember[]? | Org only. Avatar strip capped at 12 with +N overflow. |
onAvatarChange | (file: File | null) => Promise<void> | void ? | When set, avatar becomes clickable — hover scrim + built-in upload sheet (drag-drop, preview, remove). Receives the chosen File (or null for remove) on save. May return a Promise; the sheet stays open with a spinner while it resolves and surfaces a generic error on reject. |
onEditClick | () => void ? | When set, the name row shows a pencil button on group-hover. Caller owns the resulting edit dialog. |
nameAccessory | ReactNode ? | Slot rendered right of the pencil on the name row. Stays visible regardless of hover. Library stays content-agnostic — pass any node (theme toggle, status badge, etc.). |
ProfileLayoutTab
| Field | Type | Description |
|---|---|---|
value | string | Unique tab identifier. |
label | string | Tab label text. |
count | number | null? | Optional count badge. The current design omits tab counts — pass nothing to match. |
showColsToggle | boolean? | Show the 1/2-col layout toggle for this tab. |
render | (cols: 1 | 2, scrolled: boolean) => ReactNode | Renders tab content. For sticky elements inside the tab, offset by the exported TOPBAR_H_SMALL constant (the collapsed top-bar height) using the scrolled flag. |
TOPBAR_H_SMALL is exported alongside ProfileLayout — use it to position
sticky elements in a tab's render output relative to the collapsed top bar.
ProfileLayoutFact
| Field | Type | Description |
|---|---|---|
label | string | Key label in the left column (uppercase mono). |
value | string | Value displayed in the right column. |
ProfileLayoutMember
| Field | Type | Description |
|---|---|---|
id | string | Unique member identifier (used as React key). |
name | string | Display name. Initials derive for the avatar chip. |