PageHeader

Cover, face and actions at the top of a profile.

A profile screen on a dark phone: a blue-to-violet gradient banner, a round photograph overlapping its bottom edge with a verification rosette, the name Khalid Abdi, the handle @Khalidabdi1, a two-line bio, a link and a joined date, the counts 188 Following and 533 Followers, and two buttons reading Share profile and Edit profile.
The profile screen version, running in the example app.

The top of a profile screen: a banner, the face over it, who the page belongs to, and what you can do about them.

Two variants, for the two places it lives. card is a surface of its own, for a profile that appears inside a scroll of other cards. page drops the surface and lets the banner run to the screen's edges, for the screen the profile is.

It draws the header and nothing under it. The tabs, the grid and the feed below are yours — put the header at the top of a ScrollView and carry on.

For a name and avatar inside a feed row rather than at the top of a screen, use Post.

Installation

PageHeader ships with the library — no separate install.

import { PageHeader, Button, Badge, Text, RefreshControl, LinkIcon, CameraIcon, CalendarIcon, GlobeIcon, EllipsisIcon, ShareNodesIcon, PencilIcon, PlusIcon } from 'panelui-native';
import { View, Image, ScrollView } from 'react-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add page-header

Usage

<PageHeader>
  <PageHeader.Cover />
  <PageHeader.Avatar source={{ uri: face }} fallback="OR" verified />
  <PageHeader.Content>
    <PageHeader.Title>Olivia Rhye</PageHeader.Title>
    <PageHeader.Description>olivia@panelui.dev</PageHeader.Description>
  </PageHeader.Content>
  <PageHeader.Actions>
    <Button variant="secondary" className="flex-1">Message</Button>
    <Button className="flex-1">Follow</Button>
  </PageHeader.Actions>
</PageHeader>

Composition

<PageHeader>
  <PageHeader.Cover />          {/* the banner, or a gradient */}
  <PageHeader.Row>              {/* the face, and anything beside it */}
    <PageHeader.Avatar />
    <PageHeader.Stats>
      <PageHeader.Stat value="788" label="Followers" />
    </PageHeader.Stats>
  </PageHeader.Row>
  <PageHeader.Content>
    <PageHeader.Title>…</PageHeader.Title>
    <PageHeader.Description>…</PageHeader.Description>
    <PageHeader.Meta icon={<LinkIcon />}>…</PageHeader.Meta>
  </PageHeader.Content>
  <PageHeader.Actions>…</PageHeader.Actions>
</PageHeader>
  • PageHeader.Cover — The banner. Give it a source for an image; without one it draws a gradient, because a header with no banner still has to read as a header. height sets the band, alt describes the picture — left out, it is treated as decoration.
  • PageHeader.Avatar — The face, in a ring of whatever surface is behind it. It lifts over the cover's bottom edge on its own: the root looks for a PageHeader.Cover among its children, so a header without one leaves the face where it is. overlap overrides that either way. Pass children to put something else in the ring — a logo, a monogram, a live thumbnail.
  • PageHeader.Row — The face, and whatever sits beside it — for the profile that puts its counts next to the picture rather than under the name. Leave it out when the face stands alone; PageHeader.Avatar carries its own inset then.
  • PageHeader.Content — The text block. Everything in it takes the header's alignment.
  • PageHeader.Title — Whose page it is. Announces itself as a heading.
  • PageHeader.Description — The handle, the email, the one quiet line under the name.
  • PageHeader.Meta — One fact about the account — a link, a location, the month it was opened. The icon takes the muted colour without being told.
  • PageHeader.Stats — The row of counts. layout="stacked" puts the label under the figure, for counts read as a set; inline runs them together — "533 Followers" — for counts read as part of a sentence. divided rules between them.

Centred and stacked, the counts take equal widths across the row, so the middle one lands on the same centre line as the name and the buttons.

  • PageHeader.Stat — One count. Announced as a single thing — "533 Followers" — because a figure and its label read apart are two pieces of nothing. Give it an onPress and it becomes a real button.
  • PageHeader.Actions — What you can do about the account. Give the buttons className="flex-1" for the pair that splits the width evenly.

Examples

The profile card

The default. The cover is held off the card's edges and rounded on its top corners only, so its bottom edge meets the content rather than floating above it.

Nothing here says the face should lift over the cover — the card can see the cover among its children and works it out.

A profile card on a dark screen: a blue-to-violet gradient held off the card's edges and rounded on its top corners only, its bottom edge meeting the content, a round photograph centred over that edge with a verification rosette, the name Khalid Abdi, the email khalid@panelui.dev, and the buttons Message and Follow.
<PageHeader>
  <PageHeader.Cover />
  <PageHeader.Avatar source={{ uri: face }} fallback="OR" verified />
  <PageHeader.Content>
    <PageHeader.Title>Olivia Rhye</PageHeader.Title>
    <PageHeader.Description>olivia@panelui.dev</PageHeader.Description>
  </PageHeader.Content>
  <PageHeader.Actions>
    <Button variant="secondary" className="flex-1">Message</Button>
    <Button className="flex-1">Follow</Button>
  </PageHeader.Actions>
</PageHeader>

The face at the leading edge

align="start" moves the face, the text and the actions to the leading edge. Use it where the name is the first thing on the line rather than the middle of it — a card in a list of them, where a column of centred names is harder to scan than a column of left-aligned ones.

The same profile card with everything on the leading edge: an emerald-to-sky gradient cover, a photograph overlapping its bottom-left corner with a verification rosette, the name Khalid Abdi, the line Building in public, Nairobi, the counts 127 Components and 4.2K Followers ruled apart, and a full-width Follow button beside a share button.
<PageHeader align="start">
  <PageHeader.Cover colors={['#34d399', '#0ea5e9']} />
  <PageHeader.Avatar size="lg" source={{ uri: face }} fallback="KA" verified />
  <PageHeader.Content>
    <PageHeader.Title>Khalid Abdi</PageHeader.Title>
    <PageHeader.Description>Building in public, Nairobi</PageHeader.Description>
    <PageHeader.Stats layout="inline" divided className="pt-2">
      <PageHeader.Stat value="127" label="Components" />
      <PageHeader.Stat value="4.2K" label="Followers" />
    </PageHeader.Stats>
  </PageHeader.Content>
  <PageHeader.Actions>
    <Button className="flex-1">Follow</Button>
    <Button variant="secondary" size="icon"><ShareNodesIcon size={18} /></Button>
  </PageHeader.Actions>
</PageHeader>

A banner of your own

Cover takes children, so anything that belongs on the banner goes there — a change-photo button, a badge, a back arrow over a translucent circle.

Give it an alt when the picture carries meaning. Without one it is announced as nothing, which is right for a gradient and wrong for a photograph of the place the account is about.

<PageHeader.Cover source={{ uri: banner }} height={150} alt="The workshop at dusk">
  <View className="absolute bottom-2 end-2">
    <Button variant="secondary" size="icon" onPress={pickCover}>
      <CameraIcon size={18} />
    </Button>
  </View>
</PageHeader.Cover>

Counts that go somewhere

A count with an onPress is a real button — press feedback and a button role — rather than a view with a handler on it. It is announced as one thing, so a screen reader says "533 Followers" instead of "533" and then "Followers".

<PageHeader.Stats>
  <PageHeader.Stat value="3" label="Posts" />
  <PageHeader.Stat
    value="788"
    label="Followers"
    onPress={() => router.push('/followers')}
  />
  <PageHeader.Stat
    value="1,882"
    label="Following"
    onPress={() => router.push('/following')}
  />
</PageHeader.Stats>

Pull to refresh

A profile header is the top of a scroll somebody pulls on, so give the scroll a RefreshControl. The spinner appears above the cover, the content holds its place, and both settle back when the refresh resolves — the gesture every social app has trained people to try first.

Tint it from a token rather than leaving it to the platform default, which is a mid grey that disappears against a dark cover.

const [refreshing, setRefreshing] = useState(false);
const tint = useCSSVariable('--color-muted-foreground');

async function refresh() {
  setRefreshing(true);
  try {
    await reloadProfile();
  } finally {
    setRefreshing(false);
  }
}

<ScrollView
  refreshControl={
    <RefreshControl
      refreshing={refreshing}
      onRefresh={refresh}
      tintColor={typeof tint === 'string' ? tint : undefined}
    />
  }
>
  <PageHeader variant="page" align="start">…</PageHeader>
  {/* the feed under it */}
</ScrollView>

A mark instead of a face

PageHeader.Avatar takes children, which fill the ring in place of the face. Use it for a logo: the face crops to fill, and a logo cropped to a circle is a logo with its corners cut off.

What you put there fills the ring edge to edge, so give it its own background and hold the mark clear of the edge yourself.

<PageHeader.Avatar>
  <View className="h-full w-full items-center justify-center bg-card">
    <Image
      source={mark}
      style={{ width: 46, height: 46 }}
      resizeMode="contain"
      accessibilityLabel="PanelUI"
    />
  </View>
</PageHeader.Avatar>

Versions

Profile screen

variant="page" drops the card and lets the cover run to the screen's edges. Give it the status-bar inset on top of the band you want, so the gradient reaches the top of the screen rather than stopping under the clock.

The counts are inline here because they read as part of a sentence rather than as a set to compare.

A profile screen on a dark phone: a blue-to-violet gradient banner, a round photograph overlapping its bottom edge with a verification rosette, the name, the handle, a bio, a link and a joined date, the counts 188 Following and 533 Followers, and the buttons Share profile and Edit profile.
const insets = useSafeAreaInsets();

<PageHeader variant="page" align="start">
  <PageHeader.Cover height={insets.top + 120} />
  <PageHeader.Avatar source={{ uri: face }} fallback="KA" verified />
  <PageHeader.Content>
    <PageHeader.Title>Khalid Abdi</PageHeader.Title>
    <PageHeader.Description>@Khalidabdi1</PageHeader.Description>
    <Text className="pt-2">
      Building high-performance React Native components for Expo.
    </Text>
    <View className="flex-row flex-wrap gap-x-4 pt-2">
      <PageHeader.Meta icon={<LinkIcon size={14} />}>panelui.dev</PageHeader.Meta>
      <PageHeader.Meta icon={<CalendarIcon size={14} />}>
        Joined February 2022
      </PageHeader.Meta>
    </View>
    <PageHeader.Stats layout="inline" className="pt-2">
      <PageHeader.Stat value="188" label="Following" onPress={openFollowing} />
      <PageHeader.Stat value="533" label="Followers" onPress={openFollowers} />
    </PageHeader.Stats>
  </PageHeader.Content>
  <PageHeader.Actions>
    <Button variant="secondary" className="flex-1">Share profile</Button>
    <Button className="flex-1">Edit profile</Button>
  </PageHeader.Actions>
</PageHeader>

Counts beside the face

No cover, so the face has nothing to overlap and does not lift. PageHeader.Row puts the counts next to the picture instead of under the name, which is the shape that fits three of them on a phone.

badge takes the corner the rosette would have had. Resolve the glyph's colour against the surface you fill the badge with — a circle filled in the foreground colour with an icon that inherited the foreground colour is invisible in a light theme.

A dark profile screen with no banner: a round photograph at the leading edge carrying a plus button in its lower corner, and beside it the counts 3 Posts, 788 Followers and 1,882 Following, then the name, a category, a bio, a link, and the buttons Edit profile, Share profile and an overflow button.
<PageHeader variant="page" align="start">
  <PageHeader.Row>
    <PageHeader.Avatar
      size="lg"
      source={{ uri: face }}
      fallback="KA"
      badge={<StoryBadge />}
    />
    <PageHeader.Stats className="flex-1">
      <PageHeader.Stat value="3" label="Posts" />
      <PageHeader.Stat value="788" label="Followers" onPress={openFollowers} />
      <PageHeader.Stat value="1,882" label="Following" onPress={openFollowing} />
    </PageHeader.Stats>
  </PageHeader.Row>
  <PageHeader.Content>
    <PageHeader.Title>Khalid Abdi</PageHeader.Title>
    <PageHeader.Description>Science &amp; Technology</PageHeader.Description>
  </PageHeader.Content>
  <PageHeader.Actions>
    <Button variant="secondary" className="flex-1">Edit profile</Button>
    <Button variant="secondary" className="flex-1">Share profile</Button>
    <Button variant="secondary" size="icon"><EllipsisIcon size={18} /></Button>
  </PageHeader.Actions>
</PageHeader>

/** The glyph is resolved against the page, not left to inherit. */
function StoryBadge() {
  const background = useCSSVariable('--color-background');
  return (
    <View className="h-7 w-7 items-center justify-center rounded-full border-2 border-background bg-foreground">
      <IconColorProvider color={typeof background === 'string' ? background : undefined}>
        <PlusIcon size={13} />
      </IconColorProvider>
    </View>
  );
}

Everything centred

The default alignment, at full screen. The face is centred over the cover and the counts sit under the name rather than beside it, which is the arrangement that reads when the name is the first thing on the screen rather than one row of many.

A dark profile screen with a cyan-to-blue gradient banner, a round photograph centred over its bottom edge with a verification rosette, and centred under it the name, an email, a location line, three evenly spaced counts reading 127 Components, 533 Followers and 0.92 Version, and the buttons Message and Follow.
<PageHeader variant="page">
  <PageHeader.Cover height={insets.top + 140} />
  <PageHeader.Avatar source={{ uri: face }} fallback="KA" verified />
  <PageHeader.Content>
    <PageHeader.Title>Khalid Abdi</PageHeader.Title>
    <PageHeader.Description>khalid@panelui.dev</PageHeader.Description>
    <PageHeader.Meta icon={<GlobeIcon size={14} />} className="pt-1">
      Remote · GMT+3
    </PageHeader.Meta>
    <PageHeader.Stats className="pt-3">
      <PageHeader.Stat value="127" label="Components" />
      <PageHeader.Stat value="533" label="Followers" onPress={openFollowers} />
      <PageHeader.Stat value="0.92" label="Version" />
    </PageHeader.Stats>
  </PageHeader.Content>
  <PageHeader.Actions>
    <Button variant="secondary" className="flex-1">Message</Button>
    <Button className="flex-1">Follow</Button>
  </PageHeader.Actions>
</PageHeader>

A hero, and no face

Every part is optional, and this is what leaving the avatar out buys: a tall banner that is the account itself. A face over it would be a second subject on the same screen.

colors takes a ramp of your own — real colour strings, because a gradient is painted rather than classed. Resolve tokens with useCSSVariable if that is where they come from.

divided rules the counts apart. Use it where each figure is a different kind of thing — karma, posts, an age — rather than three of one kind, which read fine on their own spacing.

A dark screen with a tall pink-to-indigo gradient filling the top half and no avatar over it, then the title Coastline Weekly with an Edit button opposite, a handle and follower line, three counts ruled apart reading 73 Karma, 33 Posts and 2mo Age, and a full-width Follow button beside a share button.
<PageHeader variant="page" align="start">
  <PageHeader.Cover
    height={insets.top + 300}
    colors={['#f472b6', '#8b5cf6', '#312e81']}
  />
  <PageHeader.Content className="pt-4">
    <View className="flex-row items-center gap-3">
      <PageHeader.Title className="flex-1">Coastline Weekly</PageHeader.Title>
      <Button variant="secondary" size="sm">Edit</Button>
    </View>
    <PageHeader.Description>u/coastlineweekly · 0 followers</PageHeader.Description>
  </PageHeader.Content>
  <PageHeader.Stats divided className="px-4 pt-4">
    <PageHeader.Stat value="73" label="Karma" />
    <PageHeader.Stat value="33" label="Posts" onPress={openPosts} />
    <PageHeader.Stat value="2mo" label="Age" />
  </PageHeader.Stats>
  <PageHeader.Actions>
    <Button className="flex-1">Follow</Button>
    <Button variant="secondary" size="icon"><ShareNodesIcon size={18} /></Button>
  </PageHeader.Actions>
</PageHeader>

An organisation

A mark rather than a face. It goes in PageHeader.Avatar's children, which fill the ring in place of the face — the face crops to fill, and a logo cropped to a circle is a logo with its corners cut off.

Title takes children, so a badge goes beside the name on the same line.

A dark screen with an amber-to-pink gradient banner and the PanelUI mark in a round ring overlapping its bottom edge, then the name PanelUI with an Open source badge beside it, a description, a link and a since date, the counts 127 Components and 6 Themes ruled apart, and a full-width Install button beside an edit button.
<PageHeader variant="page" align="start">
  <PageHeader.Cover height={insets.top + 120} />
  <PageHeader.Avatar>
    <View className="h-full w-full items-center justify-center bg-card">
      <Image source={mark} style={{ width: 46, height: 46 }} resizeMode="contain" />
    </View>
  </PageHeader.Avatar>
  <PageHeader.Content>
    <View className="flex-row items-center gap-2">
      <PageHeader.Title>PanelUI</PageHeader.Title>
      <Badge variant="secondary">Open source</Badge>
    </View>
    <PageHeader.Description>
      High-performance React Native components for Expo
    </PageHeader.Description>
    <PageHeader.Stats layout="inline" divided className="pt-3">
      <PageHeader.Stat value="127" label="Components" />
      <PageHeader.Stat value="6" label="Themes" />
    </PageHeader.Stats>
  </PageHeader.Content>
  <PageHeader.Actions>
    <Button className="flex-1">Install</Button>
    <Button variant="secondary" size="icon"><PencilIcon size={18} /></Button>
  </PageHeader.Actions>
</PageHeader>

Variants

variant

  • card (default)
  • page
{/* A surface of its own, for a profile inside a scroll of other cards.
    The cover is held off the card's edges and attached to the content. */}
<PageHeader variant="card">…</PageHeader>

{/* No surface, and the banner runs to the screen's edges. For the screen
    the profile is, rather than a card about somebody on another one. */}
<PageHeader variant="page">…</PageHeader>

align

  • start
  • center (default)
{/* The face and the text centred over the cover. */}
<PageHeader align="center">…</PageHeader>

{/* Everything on the leading edge — the name is the first thing on the
    line rather than the middle of it. */}
<PageHeader align="start">…</PageHeader>

API Reference

PropTypeDefaultDescription
classNamestring

PageHeader.Root

PropTypeDefaultDescription
variantPageHeaderVariantcardcard is a surface of its own, with the cover held off its edges. page drops the surface and lets the cover run to the screen's edges.
alignPageHeaderAligncenterWhich edge the face, the text and the actions line up on. center is the profile card; start is the screen header, where the name is the first thing on the line rather than the middle of it.

PageHeader.Cover

PropTypeDefaultDescription
classNamestring
sourceImageSourcePropTypeThe banner. Left out, the cover draws a gradient instead.
heightnumberHow tall the band is.
colorsreadonly [string, string, ...string[]]The gradient, when there is no image. Two colours or more, as real colour strings — this is painted rather than classed.
altstringWhat the banner shows. Left out, it is treated as decoration.

PageHeader.Avatar

PropTypeDefaultDescription
classNamestring
sizeAvatarSizeName'xl'How big the face is.
verifiedbooleanDraws the verification rosette in the face's bottom corner.
badgeReactNodeAnything else for that corner — a camera button, a presence dot, a "+". Wins over verified.
overlapbooleanWhether the face lifts over the cover's bottom edge. Set by the presence of a PageHeader.Cover; pass it to override that either way.

PageHeader.Meta

PropTypeDefaultDescription
iconReactNodeA glyph before the line. Takes the muted colour without being told.

PageHeader.Stats

PropTypeDefaultDescription
layoutPageHeaderStatsLayoutstacked puts the label under the figure, for a row of counts read as a set. inline runs them together — "533 Followers" — for counts read as part of a sentence.
dividedbooleanRule between each count and the next.

PageHeader.Stat

PropTypeDefaultDescription
classNamestring
valueReactNodeThe figure.
labelReactNodeWhat it counts.
dividedbooleanRule before this count. PageHeader.Stats sets it; pass it to override.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Why the cover is inset

In card the cover is held off the card's edges by the card's own padding, and rounded on its top corners only. Its bottom edge is square and meets the content, so the two read as one object rather than as a picture with a panel under it.

The radii are a pair on the theme's own scale — rounded-3xl on the card, rounded-2xl on the cover — one step apart, which in most themes is exactly the padding between them. That keeps the curves concentric without a fixed number that would be wrong in half of them.

Restyle the card's radius and give the cover the matching one yourself. Both arrive as className strings, which the component cannot read.

The face lifts itself

PageHeader.Avatar pulls up over the cover's bottom edge by half its own diameter, and works out whether to: the root looks for a PageHeader.Cover among its children and says so through context.

A header with no cover has nothing to overlap, and an avatar that lifted anyway would hang off the top of it. Pass overlap to decide it yourself — for a cover rendered conditionally, where the children the root can see are not the children it will draw.

The ring is a surface, not a border

The ring around the face is drawn in the surface behind it — the card in card, the page in page — rather than in the border colour. A border reads as an outline on top of the cover; a ring in the surface behind reads as the face being punched out of it.

That is also why the badge hangs off a second view rather than off the ring: the ring clips to a circle, and a badge inside it would be cut in half by the very edge it is meant to sit against.

The rosette is at the bottom

Avatar.Badge pins to the top corner, which is where an unread count belongs. A verification mark belongs at the bottom, beside the name it is vouching for, so PageHeader.Avatar draws its own corner instead.

badge takes that corner for anything else — a camera button, a presence dot, a "+" to add to a story — and wins over verified.

The gradient is two series tokens

A cover with no source draws a gradient between --color-chart-2 and --color-chart-5. They are series tokens rather than a pair of hexes, so an app that puts its charts on brand puts this on brand with them.

Pass colors for a gradient of your own — two or more real colour strings, because a gradient is painted rather than classed. Resolve tokens with useCSSVariable if that is where they come from.

Give each header in an app its own ramp. A gallery of profiles that all open on the same banner reads as one page that failed to change.

At full screen, the cover needs the status bar

variant="page" runs the cover to the screen's edges, top included. Give height the safe-area inset on top of the band you want — insets.top + 120 — or the gradient stops under the clock and the header opens with a strip of page above it.

The header draws nothing over the cover, so a full-screen profile needs its own way back. Put it in PageHeader.Cover's children, or absolutely over the whole thing.

It is the top of a scroll, so let people pull on it

A profile header is the first thing under the status bar and the thing people pull down on to see whether anything is new. Give the scroll it sits in a RefreshControl — the spinner appears above the cover, the header holds its place, and both settle back when the refresh resolves.

Tint the spinner from a token. The platform default is a mid grey that disappears against a dark cover.

The header does not own the scroll, so this is yours to wire — which is also what lets the same refresh cover the feed under it.

Centred counts are measured, not just centred

A stacked row of counts under a centred name takes equal widths across the row rather than sitting at its content width.

Left to their content, the counts are centred as a block but not as figures: "Followers" is twice the width of "Posts", so the middle count sits off the centre line the name and the buttons are on. The row reads as very slightly wrong without it being obvious why.

Public exports

Values: PageHeader

Types: PageHeaderProps, PageHeaderRootProps, PageHeaderCoverProps, PageHeaderAvatarProps, PageHeaderRowProps, PageHeaderContentProps, PageHeaderMetaProps, PageHeaderStatsProps, PageHeaderStatProps, PageHeaderActionsProps, PageHeaderVariant, PageHeaderAlign, PageHeaderStatsLayout

On this page