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
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.
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:
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.
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.
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 six
pixels below the rule in the columns either side of it. That is not an estimate — it is the
padding token's value, measured in the browser.
bleed cancels it:
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. That distinction is the whole
implementation. The bar used to bleed with negative margins and a width of 100% + 2 × the padding — which worked, and was 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. The mobile sweep found it
as a 6px overflow on a zone that happened not to clip.
Now 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.
Measured before any of it was fixed: a 256px column, a rule 223px long, and a 33px gap before
the column's edge — because bleed emitted both w-full and w-auto, which are equal
specificity, and Tailwind sorts w-auto first. The bar moved and never widened.
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 520 px wide, and a phone gives it 375 — measured on the console layout, 146 px of it were clipped by the content column, 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. That turns the
cluster into a named, focusable region so a keyboard user can scroll it at all.
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.
Measured by a developer who put a workspace brand in start at 375 px: the bar came out
523 px wide, the sign-in control was 0 px visible, and the language picker showed 36 of
its 160. Moving the brand into the default slot brought the bar to 375 px with every control
reachable — because the default slot is the one that scrolls.
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, that cluster becomes a focusable region,
and a cluster with nothing in it becomes a 0 × 0 keyboard stop announcing a region with no
content. A reader tabs into a name and finds nothing 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. 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.