Skip to content

Tsai top bars

Token-backed top bars for home and secondary pages, plus a page composition that moves its heading into a pinned bar when scrolling starts.

HomeTopBar example PageTopBar example PageWithTopBar example PageWithSearchTopBar example

Home top bar

HomeTopBar is 76 pixels high and accepts lists of widgets at both directional edges. Items in each list use the Penpot eight-pixel gap. Its background uses the theme's reusable top scrim asset, while the reusable UserPill and HomeTopBarAction use token-backed glass surfaces and backdrop blur.

Place the bar above the page scroll view in a Stack. Give the scrollable a 76-pixel leading content inset instead of reserving a separate layout row for the bar. The content then starts below the bar at rest and scrolls underneath its scrim and glass controls.

Stack(
  fit: StackFit.expand,
  children: [
    SingleChildScrollView(
      padding: const EdgeInsets.only(top: 76),
      child: const HomeContents(),
    ),
    const PositionedDirectional(
      top: 0,
      start: 0,
      end: 0,
      child: HomeTopBar(/* ... */),
    ),
  ],
)
HomeTopBar(
  leading: [
    UserPill(
      name: user.displayName,
      initials: user.initials,
      avatarUrl: user.avatarUrl,
      semanticLabel: 'Open profile',
      onPressed: openProfile,
    ),
  ],
  trailing: [
    HomeTopBarAction(
      icon: const TsaiIcon(LucideIcons.scan_line),
      semanticLabel: 'Scan',
      onPressed: scan,
    ),
    HomeTopBarAction(
      icon: const TsaiIcon(LucideIcons.bell),
      semanticLabel: 'Notifications',
      showIndicator: hasUnreadNotifications,
      onPressed: openNotifications,
    ),
  ],
)

UserPill composes the public 32-pixel Avatar. It displays initials while the image is loading and when the URL is absent, empty, or fails. onPressed is optional, so the pill can be display-only.

HomeTopBarAction.showIndicator is a generic attention state. It is not tied to notifications and can be used by any home action.

Page top bar

PageTopBar is 56 pixels high and fills the available width. Its leading, title, and trailing content uses symmetric one-two-one tracks. Showing or hiding the title therefore does not resize the bar or move either action list. The bar uses the token-backed translucent canvas surface and background blur, so scrolling content remains visible without competing with its controls.

PageTopBar(
  leading: [
    PageTopBarAction(
      icon: const TsaiIcon(LucideIcons.arrow_left),
      semanticLabel: 'Back',
      onPressed: Navigator.of(context).pop,
    ),
  ],
  title: 'Card details',
  trailing: [
    PageTopBarAction(
      icon: const TsaiIcon(LucideIcons.plus),
      semanticLabel: 'Add',
      onPressed: addCard,
    ),
    PageTopBarAction(
      icon: const TsaiIcon(LucideIcons.ellipsis),
      semanticLabel: 'More',
      onPressed: openMenu,
    ),
  ],
)

title accepts text and applies the Penpot large-body text style. leading and trailing are external widget lists; they can contain actions, text, or another compact composition. The bar uses a symmetric one-two-one track layout to keep the title geometrically centered and clip each track inside its own bounds.

Both action types require a TsaiIcon and semantic label. Set onPressed to null for a disabled action.

Scroll-owning page

PageWithTopBar keeps PageTopBar pinned above one full-height SingleChildScrollView. A leading inset inside that scroll document places the expanded heading below the bar at rest. The heading and body then move under the bar as one document. At rest, the bar has no background or backdrop blur; its glass surface appears after the scroll offset becomes positive. Its heading and optional subtitle are text values, so the title area cannot be replaced with an unrelated widget. After any positive scroll offset, the expanded TsaiTitle is replaced by a moving primary heading while the subtitle is removed. The heading moves into the centered title position as its typography animates from headingExtraLarge to bodyLargeMedium. Returning to offset zero completes the reverse movement before restoring TsaiTitle. Motion uses the token-backed ease-in-out curve and becomes immediate when reduced motion is enabled.

PageWithTopBar(
  heading: 'Portfolio',
  subtitle: 'Main account',
  leading: [
    PageTopBarAction(
      icon: const TsaiIcon(LucideIcons.arrow_left),
      semanticLabel: 'Back',
      onPressed: Navigator.of(context).pop,
    ),
  ],
  trailing: [
    PageTopBarAction(
      icon: const TsaiIcon(LucideIcons.plus),
      semanticLabel: 'Add position',
      onPressed: addPosition,
    ),
    PageTopBarAction(
      icon: const TsaiIcon(LucideIcons.ellipsis),
      semanticLabel: 'More',
      onPressed: openMenu,
    ),
  ],
  body: const PortfolioContents(),
)

The body must have finite intrinsic height. Do not pass a primary ListView or another unbounded scroll view; build rows or sections directly so the heading and content share the owned scroll position.

Pass controller to observe or change the position. When it is omitted, PageWithTopBar creates and disposes its own controller. physics customizes the owned scrollable.

PageWithSearchTopBar pins the Penpot scroll-state composition: a 56-pixel PageTopBar, an eight-pixel gap, a 40-pixel TsaiSearchInput, and an eight-pixel lower inset inside one 112-pixel glass header. The owned document starts with a 120-pixel inset and scrolls underneath that header.

PageWithSearchTopBar(
  title: 'Card details',
  search: TsaiSearchInput(
    controller: searchController,
    onChanged: filterCards,
  ),
  leading: [
    PageTopBarAction(
      icon: const TsaiIcon(LucideIcons.arrow_left),
      semanticLabel: 'Back',
      onPressed: Navigator.of(context).pop,
    ),
  ],
  body: const CardDetailsContent(),
)

The search slot is typed as TsaiSearchInput, preserving the design geometry while leaving query ownership with the caller. As with PageWithTopBar, the body must have finite intrinsic height and must not contain another unbounded primary scroll view.

Safe areas

The 76- and 56-pixel values are the visual component heights from Penpot. HomeTopBar, PageTopBar, PageWithTopBar, and PageWithSearchTopBar do not add a system safe area. Place the composition inside SafeArea when the screen can extend under system status regions.