---
title: Shell Bar
description: Aligned column head that draws an app shell's top rule
visibility: guest
draft: false
---

# 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

:::preview{title="A title and its actions"}
<x-wirekit::shell-bar>
    <x-wirekit::heading level="2" size="md">Transactions</x-wirekit::heading>
    <x-slot:end>
        <x-wirekit::button size="sm" intent="neutral" surface="outline">Export</x-wirekit::button>
        <x-wirekit::button size="sm">New payment</x-wirekit::button>
    </x-slot:end>
</x-wirekit::shell-bar>
:::

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.

:::preview{title="One rule across three columns" wide bleed}
<x-wirekit::app-shell viewport header-placement="content" style="height: 15rem; overflow: hidden;">
    <x-slot:rail>
        <x-wirekit::app-rail>
            <x-slot:brand>
                <x-wirekit::shell-bar padding="none" align="center">
                    <x-wirekit::avatar initials="SW" size="sm" />
                </x-wirekit::shell-bar>
            </x-slot:brand>
            <x-wirekit::app-rail.item href="#" icon="home" label="Overview" />
            <x-wirekit::app-rail.item href="#" icon="chart-bar" label="Insights" :active="true" />
        </x-wirekit::app-rail>
    </x-slot:rail>
    <x-slot:sidebar>
        <x-wirekit::sidebar variant="flush" label="Payments navigation">
            <x-slot:header>
                <x-wirekit::shell-bar padding="sm" bleed>
                    <x-wirekit::text weight="medium" size="sm">Payments</x-wirekit::text>
                </x-wirekit::shell-bar>
            </x-slot:header>
            <x-wirekit::sidebar.item href="#" :active="true">Terminals</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.item href="#">Payouts</x-wirekit::sidebar.item>
        </x-wirekit::sidebar>
    </x-slot:sidebar>
    <x-slot:header>
        <x-wirekit::shell-bar label="Terminal sections">
            <x-slot:start>
                <x-wirekit::sidebar.toggle class="lg:hidden" aria-label="Open navigation" />
            </x-slot:start>
            <x-wirekit::text size="sm" variant="muted">Terminal A920 Pro</x-wirekit::text>
            <x-slot:end>
                <x-wirekit::button size="sm">Create</x-wirekit::button>
            </x-slot:end>
        </x-wirekit::shell-bar>
    </x-slot:header>
    <x-wirekit::main max="none">
        <x-wirekit::text size="sm" variant="muted">The content column fills whatever width is left.</x-wirekit::text>
    </x-wirekit::main>
</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:

:::preview{title="A title bar over a tab strip" wide}
<x-wirekit::stack gap="none" style="width: 100%;">
    <x-wirekit::shell-bar :rule="false">
        <x-wirekit::heading level="2" size="md">Invoicing</x-wirekit::heading>
        <x-slot:end>
            <x-wirekit::button size="sm">Upload</x-wirekit::button>
        </x-slot:end>
    </x-wirekit::shell-bar>
    <x-wirekit::shell-bar>
        <x-wirekit::navbar.item href="#" :active="true">All</x-wirekit::navbar.item>
        <x-wirekit::navbar.item href="#">Review</x-wirekit::navbar.item>
        <x-wirekit::navbar.item href="#">Approved</x-wirekit::navbar.item>
    </x-wirekit::shell-bar>
</x-wirekit::stack>
:::

## 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.

:::preview{title="Centered, with no rule"}
<x-wirekit::shell-bar align="center" :rule="false" style="border: 1px dashed var(--color-wk-border); border-radius: var(--radius-wk-md);">
    <x-wirekit::badge>Centered content</x-wirekit::badge>
</x-wirekit::shell-bar>
:::

## 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:

:::preview{title="A head that spans its column"}
<x-wirekit::sidebar variant="flush" label="Payments navigation" style="width: 14rem; height: 12rem;">
    <x-slot:header>
        <x-wirekit::shell-bar padding="sm" bleed>
            <x-wirekit::text weight="medium" size="sm">Payments</x-wirekit::text>
        </x-wirekit::shell-bar>
    </x-slot:header>
    <x-wirekit::sidebar.item href="#" :active="true">Terminals</x-wirekit::sidebar.item>
    <x-wirekit::sidebar.item href="#">Payouts</x-wirekit::sidebar.item>
</x-wirekit::sidebar>
:::

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.

## 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.

## 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` | `true` | The bottom border. This is the shell's horizontal rule. Set `false` for a second, stacked bar that should not draw a line of its own |
| `padding` | `'none' \| '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 |
| `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. Needed only when that cluster holds non-interactive content — 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`](./app-shell.md)
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`:

```blade
<x-wirekit::shell-bar label="Terminal sections">
    <x-slot:start>
        {{-- 1. Pinned, so it stays reachable when the sections beside it scroll. --}}
        <x-wirekit::sidebar.toggle class="lg:hidden" 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>
```

## Keyboard Interaction

The bar is a layout band and adds no keyboard model of its own — it takes no focus and binds
no keys unless you pass `label` (see above). 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.

## See Also

- [App Rail](/components/app-rail) — the module rail whose head this bar draws
- [App Shell](/components/app-shell) — the layout that puts the columns side by side
- [Sidebar](/components/sidebar) — the second navigation column
