Skip to main content
Urbicon UI

Sticky Pinning

Pin the toolbar, column header, and group header to the top of the scroll ancestor on long lists. Keeps context visible while scrolling through hundreds of rows.

Overview

The sticky prop keeps the table's three contextual layers in view while the rows scroll past them: the toolbar (L1), the column header (L2), and the group header (L3, when grouping is active). Each pinned layer sits below the one above it, so they stack instead of overlapping. If your app shell has a fixed top bar, stickyOffset moves the whole stack down by its height.

Columns scroll with their rows; nothing pins to the left or right edge. For a table wider than the viewport, reach for fit="viewport" (see below): it contains both axes of scroll inside the table, so the page never scrolls sideways.

Playground

sticky
stickyOffset: 48px

Scroll the page to see the pinned layer(s). Adjust stickyOffset to simulate a fixed app-shell top bar.

Grouping: Department
Name
Role
Department
Location
Platform (16 items)
Emma Wilson
Staff Engineer
Platform
Berlin
Sofia Martinez
Engineering Manager
Platform
Munich
Noah Kim
DevOps Engineer
Platform
Hamburg
Ethan Müller
QA Engineer
Platform
Hamburg
Emma Wilson
Staff Engineer
Platform
Berlin
Sofia Martinez
Engineering Manager
Platform
Munich
Noah Kim
DevOps Engineer
Platform
Hamburg
Ethan Müller
QA Engineer
Platform
Hamburg
Emma Wilson
Staff Engineer
Platform
Berlin
Sofia Martinez
Engineering Manager
Platform
Munich
Noah Kim
DevOps Engineer
Platform
Hamburg
Ethan Müller
QA Engineer
Platform
Hamburg
Emma Wilson
Staff Engineer
Platform
Berlin
Sofia Martinez
Engineering Manager
Platform
Munich
Noah Kim
DevOps Engineer
Platform
Hamburg
Ethan Müller
QA Engineer
Platform
Hamburg
Design (12 items)
Liam Chen
Product Designer
Design
Hamburg
Olivia Brown
UX Researcher
Design
Munich
Isabella Singh
Design Lead
Design
Berlin
Liam Chen
Product Designer
Design
Hamburg
Olivia Brown
UX Researcher
Design
Munich
Isabella Singh
Design Lead
Design
Berlin
Liam Chen
Product Designer
Design
Hamburg
Olivia Brown
UX Researcher
Design
Munich
Isabella Singh
Design Lead
Design
Berlin
Liam Chen
Product Designer
Design
Hamburg
Olivia Brown
UX Researcher
Design
Munich
Isabella Singh
Design Lead
Design
Berlin
Product (12 items)
James Park
Frontend Developer
Product
Remote
Lucas Weber
Backend Developer
Product
Berlin
Mia Zhang
Product Manager
Product
Remote
James Park
Frontend Developer
Product
Remote
Lucas Weber
Backend Developer
Product
Berlin
Mia Zhang
Product Manager
Product
Remote
James Park
Frontend Developer
Product
Remote
Lucas Weber
Backend Developer
Product
Berlin
Mia Zhang
Product Manager
Product
Remote
James Park
Frontend Developer
Product
Remote
Lucas Weber
Backend Developer
Product
Berlin
Mia Zhang
Product Manager
Product
Remote
Data (8 items)
Aisha Patel
Data Scientist
Data
Berlin
Alexander Novak
ML Engineer
Data
Munich
Aisha Patel
Data Scientist
Data
Berlin
Alexander Novak
ML Engineer
Data
Munich
Aisha Patel
Data Scientist
Data
Berlin
Alexander Novak
ML Engineer
Data
Munich
Aisha Patel
Data Scientist
Data
Berlin
Alexander Novak
ML Engineer
Data
Munich

Platform (16 items)

Design (12 items)

Product (12 items)

Data (8 items)

Modes

sticky takes boolean | 'toolbar' | 'header' | 'both'. Which layers that pins:

ValueToolbarColumn headerGroup header
false (default)scrollsscrollsscrolls
sticky / "both"pinspinspins
"toolbar"pinsscrollsscrolls
"header"scrollspinspins

"header" takes the group header with it: it is the section marker of the same header, and a group whose name scrolls away tells the reader nothing.

sticky on its own

The bare prop is the same as both. Scroll the demo's parent page to see all three layers hold.
Name
Role
Department
Location
Emma Wilson
Staff Engineer
Platform
Berlin
Liam Chen
Product Designer
Design
Hamburg
Sofia Martinez
Engineering Manager
Platform
Munich
James Park
Frontend Developer
Product
Remote
Aisha Patel
Data Scientist
Data
Berlin
Noah Kim
DevOps Engineer
Platform
Hamburg
<Table
  {items}
  {columns}
  sticky
/>

With stickyOffset for app-shell top bar

Pass the height of your fixed top bar in pixels. It shifts whichever layer pins first, so it works with every value above.
Name
Role
Department
Location
Emma Wilson
Staff Engineer
Platform
Berlin
Liam Chen
Product Designer
Design
Hamburg
Sofia Martinez
Engineering Manager
Platform
Munich
James Park
Frontend Developer
Product
Remote
Aisha Patel
Data Scientist
Data
Berlin
Noah Kim
DevOps Engineer
Platform
Hamburg
<Table
  {items}
  {columns}
  sticky="header"
  stickyOffset={64}
/>

Custom toolbar

The default toolbar is the SmartFilterBar. Override it with the toolbar snippet; the custom content inherits the same sticky wrapper.

Custom toolbar snippet

Replace SmartFilterBar entirely while keeping the sticky behavior.
<Table {items} {columns} sticky enableSmartFilter={false}>
  {#snippet toolbar()}
    <div class="flex items-center justify-between p-3">
      <h3 class="text-base font-semibold">Active employees</h3>
      <button class="...">Export CSV</button>
    </div>
  {/snippet}
</Table>

Contained scroll — fit="viewport"

Page-relative sticky pinning and in-table horizontal scroll are mutually exclusive: a single element cannot be both a sticky-pin host and a scroll ancestor. So with sticky="header", a table wider than the viewport pushes its horizontal overflow onto the page, and the whole layout scrolls sideways.

fit picks between the two models. "content", the default, is the page-relative one above. "viewport" makes the table its own scroll container: it is height-capped to the viewport (measured for you, with no max-height of your own), the column and group headers pin to the top of the box, a total summary row pins to its bottom, and the toolbar and pagination stay fixed outside the scrolling area. Only the rows scroll, horizontally and vertically.

A total summary row pins to the bottom edge of whichever scroll box the table owns — this one, or a virtualized table's virtualHeight box. A page-relative table has no bottom edge of its own, so its summary stays in the flow behind the last row. Group summary rows belong to their group and never pin.

Full-height list page

The table fills the available viewport height. Column and group headers pinned to the top of the box, toolbar fixed above it, rows scrolling in both directions — at every width.

Scroll the rows inside the frame — the toolbar, the column header and the group header stay put, and the page around the frame never moves. Switch the frame to fit="content" to feel the difference, or open the demo full-screen.

<Table
  {items}
  {columns}
  fit="viewport"
/>

fit="viewport" supersedes sticky and stickyOffset (the measured top absorbs app-shell offsets), and it has no effect on a virtualized table, which already has a scroll box of its own — virtualHeight — and pins its header and its summary against that one.

Caveats

  • Sticky pinning anchors to the nearest scrollable ancestor, so wrapping the table in a container with overflow: auto/hidden binds the pin to that container. Inside a Drawer body that is what you want. Inside an accidental overflow wrapper it is why the header stops pinning to the page.
  • Enabling sticky="header" or "both" disables the table's internal horizontal scrolling, since the scroll area cannot be both a sticky pin host and a scroll ancestor. Very wide tables fall back to page-level horizontal scrolling. Switch to fit="viewport" to contain it instead.
  • unstyled mode strips the sticky classes, because pinning is a layout function rather than pure styling. Put them back on slotClasses.toolbar, slotClasses.thead and slotClasses.groupHeader, using the offsets the table publishes on its container: --blocks-table-sticky-top (your stickyOffset), --blocks-table-toolbar-h and --blocks-table-thead-h. Each layer's top is the sum of the ones above it. Keep the sticky prop set while you do it: unstyled takes away the classes, not the behaviour, and the prop is what attaches the two height measurements. Drop it and both custom properties stay unwritten, so every layer pins at the same offset and the group header lands on top of the column header.
  • A fit="viewport" box reaches the bottom of the viewport, so it assumes nothing sits below it. Bottom padding on an ancestor, or a following sibling, pushes the page past 100dvh and you get a second scrollbar next to the table's own. The container reports its resolved mode as data-fit="viewport" or data-fit="content", so a layout can drop that inset wherever the cap applies — which is every width: main:has([data-fit='viewport']) { padding-block-end: 0 }.