Skip to content

TsaiTabs

A token-backed tab selector with natural-height, bounded viewport, and pinned sliver content compositions.

Document-scroll example Internal-scroll example Sticky-tabs example

Natural-height document

Use TsaiTabContentLayout.intrinsic when the surrounding page owns scrolling. The selected section takes its natural height, so the header, tabs, and content move as one document.

SingleChildScrollView(
  child: Column(
    children: [
      const AccountHeader(),
      TsaiTabs(
        contentLayout: TsaiTabContentLayout.intrinsic,
        sections: [
          TsaiTabSection.text(
            label: 'Activity',
            content: const ActivitySection(),
          ),
          TsaiTabSection.text(
            label: 'Statements',
            content: const StatementsSection(),
          ),
        ],
      ),
    ],
  ),
)

intrinsic is the default. Content changes use the theme motion duration and curves, animate their height, and honor MediaQuery.disableAnimations.

Fixed tabs and internal scrolling

Use viewport inside bounded height when the header and tabs must stay visible. Each content section owns its scrolling widget and therefore its scroll position.

Column(
  children: [
    const OperationsHeader(),
    Expanded(
      child: TsaiTabs(
        contentLayout: TsaiTabContentLayout.viewport,
        sections: [
          TsaiTabSection.text(
            label: 'Open',
            content: ListView.builder(
              key: const PageStorageKey('open-requests'),
              itemCount: openRequests.length,
              itemBuilder: (context, index) =>
                  RequestRow(request: openRequests[index]),
            ),
          ),
          TsaiTabSection.text(
            label: 'Closed',
            content: ListView.builder(
              key: const PageStorageKey('closed-requests'),
              itemCount: closedRequests.length,
              itemBuilder: (context, index) =>
                  RequestRow(request: closedRequests[index]),
            ),
          ),
        ],
      ),
    ),
  ],
)

Do not place viewport mode in an unbounded SingleChildScrollView. Use a ListView, CustomScrollView, or another scrollable as each section when the section can exceed the available height.

Sticky tabs

Use the standalone bar and content with one caller-owned TabController when the page header should scroll away but the tab selector should remain pinned.

class BillingScreenState extends State<BillingScreen>
    with SingleTickerProviderStateMixin {
  late final TabController controller;

  @override
  void initState() {
    super.initState();
    controller = TabController(length: 2, vsync: this);
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => CustomScrollView(
    slivers: [
      const SliverToBoxAdapter(child: BillingHeader()),
      TsaiSliverTabBar(
        controller: controller,
        tabs: const [Text('Usage'), Text('Invoices')],
      ),
      SliverToBoxAdapter(
        child: TsaiTabContent.intrinsic(
          controller: controller,
          children: const [UsageSection(), InvoiceSection()],
        ),
      ),
    ],
  );
}

Set pinned: false for a non-sticky sliver or floating: true to reveal the bar as soon as scrolling reverses.

Composition and control

TsaiTabs creates and disposes an internal TabController when controller is omitted. Supply a controller when another part of the screen needs to observe or change selection. onChanged reports both taps and programmatic controller changes.

Use TsaiTabSection(tab: ..., content: ...) for composed tab labels. The Penpot default is TsaiTabBarFit.expand; use TsaiTabBarFit.scrollable when the labels cannot share the available width.