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

┌──────────────────────────────────────────────────────────────┐
│ [logo]         [tabs — revealed on scroll]       [actions]   │  sticky top bar
├─────────────┬────────────────────────────────────────────────┤
│             │ [TabStrip]              [cols toggle]          │  sticky
│  HeroRail   ├────────────────────────────────────────────────┤
│  sticky     │                                                │
│             │  active tab content                            │
│             │                                                │
└─────────────┴────────────────────────────────────────────────┘

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.

dream.lake
Account ▾
+ new project
all7public2private5
geyang / coffee-demoprivate
14m ago
Personal sandbox for the ge-coffee-demo recordings.
3b · 1ds · 2pl
mit-csail / droid-curationprivate
2h ago
Curate teleop episodes from the droid-2024 collection.
14b · 3ds · 4pl · 2 active
mit-csail / ego4d-clip-labelspublic
6h ago
Crowd-sourced action labels on ego4d kitchen subset.
4b · 2ds · 2pl · 1 active
geyang / bench-2026public
3d ago
Public benchmark suite for foundation policies.
8b · 5ds · 3pl · 1 active
mit-csail / mujoco-eval-suiteprivate
1d ago
Eval harness over policy-v3 unrolls in MuJoCo.
6b · 2ds · 3pl
geyang / thesis-figuresprivate
5d ago
Notebooks + plots for thesis ch. 4–5.
1b · 0ds · 1pl
mit-csail / imagenet-mirrorprivate
Mar 12
Internal mirror + integrity checks of imagenet-1M / 14M.
2b · 4ds · 1pl

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:

tsx
const profile: ProfileLayoutProfile = {
  name: 'MIT CSAIL',
  handle: 'mit-csail',
  kind: 'org',
  image: '/avatars/mit-csail.png',  // ← when set, an <img> replaces the monogram
  // …
}

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.

tsx
const profile: ProfileLayoutProfile = {
  // …
  image: avatarUrl,
  onAvatarChange: async (file) => {
    if (file === null) {
      await deleteAvatar()
      setAvatarUrl(null)
      return
    }
    const { picture } = await uploadAvatar(file)
    setAvatarUrl(picture)
  },
}

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):

tsx
const profile: ProfileLayoutProfile = {
  // …
  onEditClick: () => setShowProfileSheet(true),
}

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.

tsx
const profile: ProfileLayoutProfile = {
  // …
  nameAccessory: <ThemeTogglePill value={theme} onChange={setTheme} />,
}

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:

tsx
function MyProfilePage() {
  const [tab, setTab] = useState('overview')

  return (
    <ProfileLayout
      profile={profile}
      tabs={tabs}
      tab={tab}
      onTabChange={setTab}
    />
  )
}

tab wins when both tab and defaultTab are present.

Props

PropTypeDefaultDescription
profileProfileLayoutProfileProfile data rendered in the left rail.
tabsProfileLayoutTab[]Tab descriptors with render functions.
defaultTabstringtabs[0]Initial tab in uncontrolled mode. Ignored when tab is provided.
tabstringControlled active tab. Pass alongside onTabChange to drive tab selection from outside.
onTabChange(tab: string) => voidFires when a tab change is requested. Required when using tab.
logoReactNodeSlot rendered top-left. Animates vertical position on scroll.
actionsReactNodeSlot rendered top-right (e.g. account switcher).
scrollContainerRefRefObject<HTMLElement>windowOverride scroll source. Pass a ref when embedding inside a bounded div.
classNamestringExtra classes on the root element.

ProfileLayoutProfile

FieldTypeDescription
namestringDisplay name. Initials derive for the monogram fallback.
handlestringUsername shown as @handle below the name.
kind'user' | 'org'Org accounts show a members section in the rail.
imagestring?Avatar image URL. When provided, the HeroRail renders an <img> (1:1, object-cover); when omitted, falls back to a monogram.
biostring?Optional bio paragraph.
factsProfileLayoutFact[]?Key–value pairs in the facts grid (label column, value fills).
membersProfileLayoutMember[]?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.
nameAccessoryReactNode ?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

FieldTypeDescription
valuestringUnique tab identifier.
labelstringTab label text.
countnumber | null?Optional count badge. The current design omits tab counts — pass nothing to match.
showColsToggleboolean?Show the 1/2-col layout toggle for this tab.
render(cols: 1 | 2, scrolled: boolean) => ReactNodeRenders 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

FieldTypeDescription
labelstringKey label in the left column (uppercase mono).
valuestringValue displayed in the right column.

ProfileLayoutMember

FieldTypeDescription
idstringUnique member identifier (used as React key).
namestringDisplay name. Initials derive for the avatar chip.