Skip to content

Toolbar

A sticky bar for the top or bottom of a scroll container, with an optional large title that collapses into it as you scroll. CSS does the layout and the collapse. Only browsers without scroll-driven animations need your code to write the scroll position.

Install

import "elements-kit/ui/styles.css";
import "elements-kit/ui/styles/palette/gray.css";
import "elements-kit/ui/styles/neutral/gray.css";
import "elements-kit/ui/styles/palette/mint.css";
import "elements-kit/ui/styles/accent/mint.css";
import "elements-kit/ui/button/button.css";
import "elements-kit/ui/group/group.css"; // capsules in clean and soft bars
import "elements-kit/ui/toggle/toggle.css"; // optional: on/off and tool picks
import "elements-kit/ui/toolbar/toolbar.css";

See Styles for the token system and theming.

API

<div class="screen x-frame" style="block-size: 100dvh; overflow: auto">
<header class="x-toolbar">
<button class="unset x-button" data-variant="text" data-size="2">Back</button>
<span data-title>Inbox</span>
<button class="unset x-button" data-variant="text" data-size="2">Edit</button>
</header>
<h1 class="x-large-title">Inbox</h1>
<div>…the list…</div>
</div>

The bar and the large title are direct children of a frame: an element with class="x-frame", usually the scroll container. Everything after the large title goes in one element.

A bar sizes itself from its own attributes, frame or not. The frame is what receives the bars’ heights: its scroll padding keeps focused elements and anchors clear of the bars, the large title’s collapse reads it, and --toolbar-height / --toolbar-bottom-height are set on it for your own layout. Without x-frame the bars still lay out and stick; nothing else learns their size.

Attribute (on .x-toolbar)Values
data-positiontop (default), bottom
data-variantsurface (default), clean, soft
data-size1, 2 (default), 3, 4 — the data-size of the controls it holds (see Sizing)
data-revealalways — the background, and a bar title under a large title, show from the start instead of fading in. none — never revealed: no background or hairline, and no bar title under a large title
data-inset (in an .x-card)top, bottom — the bar sits on the card’s edge and the card drops its padding there (see In a dialog)
data-radius (on a parent or the bar)any radius; buttons and capsules follow it
Attribute (on .x-large-title)Values
data-size1, 2, 3 (default)
data-alignstart (default), center
Attribute (on the bar’s [data-title])Values
data-aligncenter (default), start — at the start, filling the free space: first in the bar, at its start edge; after a back button, next to it
Token (on .x-toolbar)Default
--toolbar-button-gap--space-2 — between items in a region
--toolbar-region-gap--space-2 — between regions
--toolbar-edge-blur8px — soft only; 0 keeps the tint

Regions

The bar reads its layout from child order: first child is the start, a middle child the center, the last child the end. Wrap several items in any element to make one region.

  • A bare [data-title] is always the center, and stays centered on the bar.
  • With only two children, the start takes the free space — a title next to a back button.
  • A lone child centers across the bar.
  • A region holding a text field fills its column. A toggle’s hidden input doesn’t count.
  • Only a region holding the title shrinks. The title truncates; buttons keep their size.

Text buttons bleed past the bar’s 16px padding, so their labels line up with the large title and the content.

Large title and scroll

Where scroll-driven animations are supported (Chrome 115, Safari 26), the large title’s own view timeline drives the collapse, with no JavaScript. Elsewhere, write the scroll position in pixels as --scroll-y on the bars and the large title:

import { effect, effectScope } from "elements-kit/signals";
import { createElementScroll } from "elements-kit/utilities/element-scroll";
const screen = document.querySelector<HTMLElement>(".screen")!;
const targets = screen.querySelectorAll<HTMLElement>(".x-toolbar:not(.x-toolbar .x-toolbar), .x-large-title");
const stop = effectScope(() => {
if (CSS.supports("animation-timeline: view()")) return;
const { y } = createElementScroll(screen);
effect(() => targets.forEach((el) => el.style.setProperty("--scroll-y", `${y()}px`)));
});

A bottom bar also needs the distance left to scroll, as --scroll-y-end, so it clears at the end:

const bottom = screen.querySelector<HTMLElement>('.x-toolbar[data-position="bottom"]');
effect(() => {
y(); // re-run on scroll
const end = screen.scrollHeight - screen.clientHeight - screen.scrollTop;
bottom?.style.setProperty("--scroll-y-end", `${end}px`);
});

Without --scroll-y-end, a bottom bar keeps its background.

Write them on the bars and the title rather than on the container or <html>: only they read them, and a variable set higher up restyles the whole page on every frame. For page scroll, read window.scrollY with sync(fromEvent(window, "scroll"), () => window.scrollY) instead.

As the title scrolls under the bar it fades out over its own height. The bar’s background and title fade in over the last 40% of that distance. Both paths drive the same registered properties, so the numbers match in every browser.

Layouts

  • Bar before the title — the title collapses into the bar.
  • Bar after the title — for a search bar: the title scrolls away and the bar pins at the top.
  • No bar — the large title scrolls away with the content.
  • No large title — the bar’s title is always shown; its background appears once content scrolls under it.

--scroll-y defaults to 0px, so until a timeline or a script says otherwise the bar reads as scrolled to the top: no background, and no bar title under a large title. Server-rendered pages paint in that state before any script runs, with no flash of a filled bar. The same applies where nothing scrolls under the bar, such as short content or a list scrolling in a sibling element; to reveal the bar from another element’s scroll, write that position as --scroll-y on the bar.

Bottom bar

Add data-position="bottom" and make the bar the last child of the frame. It shows its background and hairline while content is below it, and clears over the last few pixels of scroll. When nothing scrolls, it stays clear. Browsers without scroll-driven animations always show the background; data-reveal="always" does that everywhere. It keeps focused elements above it. With short content the frame becomes a column, so the bar still sits at the bottom. For page scroll, give the frame min-block-size: 100dvh.

The bar’s height is a minimum: taller content, such as a stacked segmented control used as a tab bar, grows it. A stacked button or toggle counts too. In clean and soft bars, controls sit 16px from the bottom edge, the same as from the sides.

In a dialog

Put the bars directly in an x-card with data-inset="top" and data-inset="bottom". They span the card’s width, and the card drops its own padding on those edges. The card clips its content (overflow: hidden), which makes it a scroll container, and a sticky bar stops at its scroll container’s padding. With the padding gone, each bar sits on the card’s edge and pads itself, so no --card-padding-* overrides are needed.

<div class="x-card x-frame" data-variant="elevated" data-size="2" role="dialog" aria-labelledby="t">
<header class="x-toolbar" data-inset="top">
<span data-title data-align="start" id="t">Delete “Q3 report”?</span>
</header>
<p>It moves to Trash for 30 days.</p>
<footer class="x-toolbar" data-inset="bottom" data-position="bottom">
<div style="justify-self: end">
<button class="unset x-button" data-variant="soft" data-accent="neutral">Cancel</button>
<button class="unset x-button" data-variant="solid" data-accent="crimson">Delete</button>
</div>
</footer>
</div>

A long dialog

A card can’t scroll itself: it paints its surface with pseudo-elements, and those would scroll away with the content. Put a scroller inside the card with data-inset="fill", which bleeds to the card’s ring on every side, and put the bars and the body in it. The bars pin to the scroller, and the header’s background fades in once the body scrolls under it, as on a page. The body pads its sides with var(--card-padding), so it follows the card’s data-size.

<div class="x-card" data-variant="elevated" data-size="2" role="dialog" aria-labelledby="t">
<div class="x-frame" data-inset="fill" style="max-block-size: 360px; overflow: auto">
<header class="x-toolbar"><span data-title data-align="start" id="t">Terms of service</span></header>
<div style="padding-inline: var(--card-padding)">…</div>
<footer class="x-toolbar" data-position="bottom">
<div style="justify-self: end"><button class="unset x-button" data-variant="solid">Accept</button></div>
</footer>
</div>
</div>

Grouped bars

Nest .x-toolbar rows inside a bar to stack them as one: a title row over a search row, or a compose button over a tab bar. The outer bar sticks, paints one background, and owns the safe area. Each row lays out its regions like a bar of its own.

<header class="x-toolbar" data-variant="soft" data-size="3">
<div class="x-toolbar">
<div class="x-group" data-variant="material">…back…</div>
<span data-title>Recents</span>
<div class="x-group" data-variant="material">…more…</div>
</div>
<div class="x-toolbar">
<div class="x-group" data-variant="material">…search field…</div>
</div>
</header>

Each row is one control tall, or one capsule in clean and soft, and rows sit a bar’s gap apart: 12px in surface, 16px in clean and soft. The scroll padding counts up to three rows.

A row with its own data-variant paints itself, and the group stops painting, so a surface row inside a surface group shows one hairline, not two. In a bottom group, a painted row always shows its background.

Variants

  • surface (default) — a material background (--color-material, falling back to --color-surface) with a hairline on the content side.
  • clean — no bar. Controls float in material groups; the bar is taller so capsules sit off the screen edge.
  • soft — clean plus a gradient blur at the screen edge.
<header class="x-toolbar" data-variant="soft">
<div class="x-group" data-variant="material">
<button class="unset x-button" data-variant="text" data-size="2" data-icon aria-label="Back">…</button>
</div>
<div class="x-group" data-variant="material">
<div class="x-text-input" data-variant="soft" data-size="2">
<span>…</span>
<input class="unset" placeholder="Search" aria-label="Search" />
</div>
</div>
</header>

Use text or borderless buttons inside capsules: the capsule is their background. A borderless toggle fits the same capsule, for a pick such as a drawing tool, and fills only when pressed.

Sizing

A bar is as tall as its controls plus fixed insets. Set the bar’s data-size to match its controls’ data-size; the frame reads it to size its scroll padding.

surfaceclean, soft
top, size 256px64px
top, size 364px72px
bottom, size 256px60px
bottom, size 364px68px
two rows, top, size 3116px136px
large title, size 154px — one 30px line (--font-size-6, 24px), 16px above, 8px belowsame
large title, size 260px — one 36px line (--font-size-7, 28px)same
large title, size 3 (default)64px — one 40px line (--font-size-8, 35px)same
  • surface — 12px above and below the controls.
  • clean, soft — controls sit in 4px capsules; 16px above and 8px below at the top, 4px above and 16px below at the bottom.
  • Large title — data-size changes the type only; the padding stays. The bar beside it collapses over the title’s own height, so a smaller title collapses sooner.

Bars add safe-area-inset-top or safe-area-inset-bottom. Every size scales with --scaling. The frame’s scroll padding matches the bars, so focused elements and anchors stop below them.

Accessibility

The toolbar only paints; semantics come from your markup.

  1. Use landmarks. A top bar is a <header>, a bottom bar a <footer>. Add role="toolbar" only if you also implement its arrow-key model.
  2. Label icon-only buttons with aria-label.
  3. Keep one visible heading. While the large title is on screen, hide the bar’s title from assistive tech, and show it once the title has collapsed:
const barTitle = screen.querySelector(".x-toolbar [data-title]");
effect(() => barTitle?.toggleAttribute("aria-hidden", y() <= 0));
  1. Reduced transparency. prefers-reduced-transparency: reduce swaps the material for a solid color.
  2. Forced colors. Bars fall back to Canvas with a CanvasText edge.

The collapse follows the scroll position directly, with no animation of its own.

When not to use

  • Switching panels — that’s the Tabs pattern, not a segmented control in a bar.
  • Multi-line titles — the large title is one line, so its height is known without measuring.
  • Site navigation with many links — use a <nav> landmark.

Browser support

Works in browsers with :has() (Chrome 105, Safari 15.4, Firefox 121) and trigonometric functions in calc() (Chrome 111, Safari 15.4, Firefox 108). Without trig support the collapse falls back to a plain bar and a static title. The collapse’s progress and the edge blur are @property registrations (Chrome 85, Safari 16.4, Firefox 128); without them the collapse still works, and the soft edge loses only its blur. The soft edge uses color-mix() and backdrop-filter.

See also

  • Group — material capsules for clean and soft bars
  • Button — text buttons for bar actions
  • Toggle — on/off actions and tool picks
  • Text Input — search fields
  • Segmented Control — filters under the title or in a bottom bar