Skip to main content
Copy for LLM

Shell Bar

<x-wirekit::shell-bar> is the head of one column in an application shell: a fixed-height band with a bottom rule, holding a title, a navigation cluster or a set of actions.

Its point is what happens when you use it more than once. A console layout has two or three columns side by side — a module rail, the module's own navigation, the content — and each one needs a head. Draw those three heads by hand and their rules will disagree the first time one column gets a longer title or a taller control, on one breakpoint, without anything turning red. Place a shell bar at the head of each column instead and the rules cannot disagree, because they are the same component reading the same height token.

Usage

A title and its actions

Transactions

The default slot holds the leading cluster; the end slot holds the trailing one and is pushed to the far edge. The leading cluster is allowed to shrink and the trailing one is not — a bar whose buttons squash into unreadable slivers while a heading nobody needs in full stays intact is the wrong trade.

The rule is what aligns

Three columns, three bars, one line. Every bar reads --size-wk-shell-bar for its height, so changing that token moves all of them together.

One rule across three columns

Terminal A920 Pro

The content column fills whatever width is left.

The same shell in your own application, with nothing bounding it:

<x-wirekit::app-shell viewport header-placement="content">
    <x-slot:rail>…</x-slot:rail>
    <x-slot:header>
        <x-wirekit::shell-bar label="Terminal sections">
            {{-- Below `lg` the shell moves `rail` and `sidebar` into an off-canvas panel.
                 That is the shell's decision, not yours, so the bar owes it a handle:
                 without one a phone gets a shell whose whole navigation is unreachable. --}}
            <x-slot:start class="lg:hidden">
                <x-wirekit::sidebar.toggle aria-label="Open navigation" />
            </x-slot:start>
            …
        </x-wirekit::shell-bar>
    </x-slot:header>
    …
</x-wirekit::app-shell>

Note the three different padding values. The rail's head is none because its content is a centered mark rather than text on a spine; the second column uses sm, its own narrower tier; the content column uses the default lg, which is the page-edge spine — so a title in the bar sits on the same vertical line as the first paragraph of the content below it.

Stacking two bars

A bar can sit under another one. Turn off the rule on the first so the pair reads as one head rather than as two separated bands:

A title bar over a tab strip

Alignment

align decides how the bar distributes its two clusters. between is the default and is what a title-plus-actions bar wants. start and center are for a bar whose content is one cluster — a centered workspace mark at the head of a rail, for instance.

Centered, with no rule
Centered content

Sticky

sticky pins the bar to the top of its scrolling ancestor.

Read this before reaching for it. position: sticky sticks to the nearest ancestor that actually scrolls. In an app-shell console layout the bar sits inside the content column, and that column never scrolls — the region below it does. So sticky there sticks to something that does not move, which is to say it does nothing. That is not a defect to be fixed; it is what position: sticky means, and the console layout does not need it anyway, because the bar already stays put while the content scrolls beneath it.

Where it does work: a bar placed in ordinary document flow, on a page that scrolls as a document.

There, an element that takes the focus, or that a fragment link brings into view, comes to rest below the bar rather than under it (keeping the focus clear of a pinned bar).

Inside a padded column

A sidebar pads itself uniformly. That is right for navigation items and wrong for a chrome band: a bar placed in its header slot ends up inset on three sides, and its rule sits below the rule in the columns either side of it by exactly the sidebar's padding.

bleed cancels it:

A head that spans its column

It does not hardcode the sidebar's padding. A container that pads its children publishes --wk-nav-pad, and the zone holding the bar reads it. It defaults to zero, so bleed outside such a host does nothing rather than producing a bar that hangs off its container — and any container of your own can opt into being bleedable by publishing that one variable.

The container stands aside; the bar does not escape. Bleeding with negative margins and a width of 100% + 2 × the padding would be the fragile shape: an element that relies on overflowing its parent is at the mercy of every ancestor, and any one of them with overflow: hidden, contain: layout, a transform or a filter clips it without warning. Instead the zone drops its own inset and pulls back the column's, so the head reaches the column's edge from the inside and never leaves the element it belongs to.

The bottom is deliberately left alone: the gap under the head is what separates the rule from the first navigation item.

A bar at the foot of a column

The rule belongs between the bar and the content it separates, so a bar at the bottom of a navigation column draws it above itself:

{{-- 1. `rule="top"` puts the line between the account row and the navigation above it,
        instead of along the column's bottom edge where it would separate nothing. --}}
{{-- 2. `bleed` lets the band meet the column's edges, exactly as the head band does. --}}
<x-slot:footer>
    <x-wirekit::shell-bar padding="sm" bleed rule="top">
        <x-wirekit::profile name="Dana Ortiz" :avatar="['initials' => 'DO']" interactive />
    </x-wirekit::shell-bar>
</x-slot:footer>

Without it the two ends of one column read differently — a band at the head and a bare row at the foot — because the only rule available pointed the wrong way.

On a narrow screen

The bar keeps an exact height, so its content cannot wrap. The leading cluster therefore scrolls horizontally when it outgrows the column, rather than being cut off.

That is not a nicety. A console top bar with a few navigation items and two actions is easily wider than a phone, and clipped content is not merely unseen but unreachable, because nothing scrolls a box with no scroller. A scrolling strip of tabs is what phone-sized app bars do, and it costs a desktop nothing: the overflow never engages there.

Where that cluster holds interactive content — tabs, links, buttons — nothing more is owed: Tab reaches each item and the browser scrolls it into view. Where it holds something that is not interactive, such as a long title or a status line, pass label. The cluster is a tab stop either way — a box that scrolls has to be reachable, so that is not something a prop switches on — and what label adds is the name a reader hears on landing there, instead of an unannounced scrollable area.

Only the leading cluster scrolls — start and end do not

This is the sentence above read one slot too widely, and getting it wrong is the common mistake. The pinned slots keep their size on purpose, so whatever you put in them is added to the bar's width rather than absorbed by it — a growing control in start must not be expected to scroll, because nothing there does. On a narrow screen that pushes the trailing cluster out of a container that hides its overflow, and hidden overflow has no scroller to reach it with.

A workspace brand in start on a phone, for instance, can push the bar past the edge of the screen and leave the trailing controls partly or wholly out of view. In the default slot the same brand scrolls with the cluster, and every control stays reachable.

So start is for something whose width you know and intend to spend: a hamburger, a back arrow, a compact mark. Anything that grows with its content belongs in the default slot.

Never pass label to a bar whose leading cluster is empty

label names the leading cluster, not the bar — reading it as the bar's name is the wrong model, and it fails silently. Given a name, a cluster with nothing in it announces as a named region with no content: a reader tabs into a name and finds nothing there.

An empty cluster takes no tab stop at all now, named or not, so the worst case this warning described is gone — the stop and its focus ring are gated on the cluster holding something. The naming half still matters: a name on an empty cluster is a promise about content that is not there.

If the bar's content lives entirely in start and end, there is no leading cluster to name, so it must not be given one. Name the surrounding landmark instead.

Theming

Token Role
--size-wk-shell-bar The bar's height, and therefore the y-coordinate of every column's rule
--color-wk-rail-border The rule's color, falling back to --color-wk-border. Reading the rail role means the segment crossing a toned column matches that column's own edge instead of drawing a light hairline across a dark surface
--padding-wk-x-* Inline padding, selected by the padding prop
--gap-wk-md Gap between the leading and trailing clusters

The bar paints no background of its own. In a toned shell the rail, the second column and the content sit on three different surfaces, and a background here would flatten all three into one — undoing the tone the shell was given.

Props

<x-wirekit::shell-bar>

Prop Type Default Description
rule bool|string true The border. This is the shell's horizontal rule. true draws it below the bar; 'top' draws it above, for a bar at the FOOT of a column; false draws none, for a second stacked bar that should not carry a line of its own
padding 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' 'lg' Inline padding on the page-edge spine scale. lg is the spine; none is for a rail head whose content is centered; sm matches a sidebar's narrower tier; xs is the rung that lines a bar's content up with the navigation rows of the column it sits in — a sidebar pads itself on that step, and a bleeding foot band cancels that padding so its rule reaches the edge
align 'start' | 'center' | 'between' 'between' Main-axis distribution. between pushes the end slot to the far edge
label string|null null An accessible name for the leading cluster, not for the bar. Needed only when that cluster holds non-interactive content, and never when it is empty — an empty named cluster is a 0 × 0 keyboard stop. See below
bleed bool false Cancels the padding of a host that pads its children, so the bar and its rule span the full column. Reads the amount from the host rather than hardcoding it, and is a no-op outside one — see below
sticky bool false Pins the bar to the top of its scrolling ancestor. A no-op inside an app-shell content column — see above
scope string|null null Scoped personalization key

Slots

Slot Purpose
start A pinned leading control — a hamburger, a back arrow, a workspace mark. Keeps its size
default The scrolling middle cluster — a title, a breadcrumb, a tab strip. Gives way first
end The trailing cluster — actions, a search field, an avatar. Keeps its size

Three clusters, and only the middle one moves. The default slot scrolls horizontally once its content is wider than the bar, which on a phone is almost immediately — so a control placed there scrolls out of sight with the tabs. start is for anything that must stay reachable.

The case it exists for is the shell's own hamburger. Below lg, app-shell moves a sidebar or rail into an off-canvas panel, and the only thing that brings it back is a <x-wirekit::sidebar.toggle>. Put that toggle in start:

<x-wirekit::shell-bar label="Terminal sections">
    <x-slot:start class="lg:hidden">
        {{-- 1. Pinned, so it stays reachable when the sections beside it scroll. --}}
        <x-wirekit::sidebar.toggle aria-label="Open navigation" />
    </x-slot:start>

    {{-- 2. The scrolling cluster. Wider than the bar on a phone, and that is fine. --}}
    <x-wirekit::navbar.item href="#" :active="true">Transactions</x-wirekit::navbar.item>
    <x-wirekit::navbar.item href="#">Terminal info</x-wirekit::navbar.item>

    {{-- 3. Actions keep their size; the middle gives way. --}}
    <x-slot:end>
        <x-wirekit::button size="sm">Create payment</x-wirekit::button>
    </x-slot:end>
</x-wirekit::shell-bar>

Put a responsive class on the slot, not on the control inside it

class="lg:hidden" sits on <x-slot:start> in the example above, and the difference is visible. A start slot renders its own cluster element, and that element is a flex item like any other — so hiding only the control inside it leaves the cluster behind at zero width, still taking the bar's gap on both sides. The heading beside it then carries an indent that nothing in your markup accounts for.

The slot's attributes reach the cluster, so putting the class there takes the cluster with it:

{{-- 1. The cluster leaves with its contents — no gap, no phantom indent. --}}
<x-slot:start class="lg:hidden">
    <x-wirekit::sidebar.toggle aria-label="Open navigation" />
</x-slot:start>

{{-- 2. The control disappears and the cluster stays, taking the bar's gap with it. --}}
<x-slot:start>
    <x-wirekit::sidebar.toggle class="lg:hidden" aria-label="Open navigation" />
</x-slot:start>

The end slot behaves the same way.

Keyboard Interaction

The bar is a layout band and binds no keys of its own. Whatever you place in it keeps its own: a <x-wirekit::button> is still reached with Tab and fired with Enter or Space, a navigation link with Tab and Enter.

The one thing the bar itself takes is a tab stop on the leading cluster, because that cluster scrolls — a box that scrolls has to be reachable by keyboard, or its overflowing content is unreachable without a mouse (WCAG 2.1.1). That stop is there on every bar whose leading cluster holds something — an empty one has nothing to scroll to, so it takes no stop. What label adds is the cluster's name: with it, a reader hears the name you chose; without it, they land on a scrollable area with no announcement of its own and Tab carries on into the content. The name is opt-in for a reason — a built-in default would make every bar in a three-column shell announce the same thing.

See Also

  • App Rail — the module rail whose head this bar draws
  • App Shell — the layout that puts the columns side by side
  • Sidebar — the second navigation column

Updated in WireKit v2.64.0 (2026-10-04)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store