---
title: Sticky Panel
description: A sticky companion column with header, scrollable body, and footer
visibility: guest
draft: false
---

# Sticky Panel

`<x-wirekit::sticky-panel>` is a layout primitive for a companion column that pins itself beside an article while the page scrolls — a cart summary, a filter sidebar, a table of contents, a comparison builder. It has a non-scrolling `header` and `footer` with a scrollable body in between, and it is bounded to the viewport height so the footer never scrolls out of reach. It uses native CSS `position: sticky` — **no JavaScript**.

## Basic Usage

The default slot is the scrollable body; `header` and `footer` are optional non-scrolling slots. (The preview pins a short `max-height` so the body scrolls inside the frame.) Each line is a realistic cart row — a product-image placeholder, the name on top with its variant + quantity below, and the price kept to the right and vertically centered. Long product names truncate with an ellipsis so the row height never jumps.

:::preview{title="Header, scrollable body, footer"}
<x-wirekit::sticky-panel max-height="18rem" width="22rem" label="Cart summary">
    <x-slot:header>
        <x-wirekit::heading :level="3" size="md">Your cart</x-wirekit::heading>
    </x-slot:header>
    @php
        $cart = [
            ['name' => 'Mechanical keyboard with tactile brown switches', 'meta' => 'Black · Qty 1', 'price' => '€129', 'img' => '/placeholder/40x40?bg=1e293b&fg=ffffff&label=KB&gradient=none'],
            ['name' => 'USB-C 7-in-1 hub', 'meta' => 'Space gray · Qty 1', 'price' => '€49', 'img' => '/placeholder/40x40?bg=0ea5e9&fg=ffffff&label=Hub&gradient=none'],
            ['name' => 'Aluminum laptop stand', 'meta' => 'Silver · Qty 2', 'price' => '€78', 'img' => '/placeholder/40x40?bg=64748b&fg=ffffff&label=St&gradient=none'],
            ['name' => 'Extended felt desk mat', 'meta' => 'Charcoal · Qty 1', 'price' => '€25', 'img' => '/placeholder/40x40?bg=44403c&fg=ffffff&label=Mat&gradient=none'],
            ['name' => 'Ergonomic vertical wireless mouse', 'meta' => 'Graphite · Qty 1', 'price' => '€59', 'img' => '/placeholder/40x40?bg=4f46e5&fg=ffffff&label=Ms&gradient=none'],
            ['name' => 'Braided cable management clips', 'meta' => 'Pack of 12 · Qty 1', 'price' => '€9', 'img' => '/placeholder/40x40?bg=0f766e&fg=ffffff&label=Cl&gradient=none'],
        ];
    @endphp
    <x-wirekit::stack gap="md">
        @foreach($cart as $item)
            <x-wirekit::row gap="sm" align="center">
                <img src="{{ $item['img'] }}" alt="" style="width: 2.5rem; height: 2.5rem; flex-shrink: 0; border-radius: var(--radius-wk-md); object-fit: cover;" />
                <x-wirekit::stack gap="none" class="min-w-0 flex-1">
                    <x-wirekit::text weight="medium" class="truncate">{{ $item['name'] }}</x-wirekit::text>
                    <x-wirekit::text size="sm" variant="muted">{{ $item['meta'] }}</x-wirekit::text>
                </x-wirekit::stack>
                <x-wirekit::text weight="medium" class="shrink-0">{{ $item['price'] }}</x-wirekit::text>
            </x-wirekit::row>
        @endforeach
    </x-wirekit::stack>
    <x-slot:footer>
        <x-wirekit::button class="w-full">Checkout — €349</x-wirekit::button>
    </x-slot:footer>
</x-wirekit::sticky-panel>
:::

## In a Two-Column Layout

Place the panel beside your article in a flex row. As the article scrolls, the panel stays pinned `offset` from the top. Below the `hideBelow` breakpoint it un-sticks and flows inline (set `mobile-behavior="hide"` to remove it on mobile instead).

**Scroll inside the frame below** — the order summary stays pinned while the (taller) article moves past it. On a real page the page itself is the scroll context; here the demo pins a fixed-height scroll region so you can see the stick without leaving the docs.

:::preview{title="Sticky summary beside a scrolling article"}
<x-wirekit::scroll-area style="height: 22rem; border: 1px solid var(--color-wk-border); border-radius: var(--radius-wk-lg); padding: 1rem;">
    <x-wirekit::grid cols="1 sm:2" gap="lg" align="start">
        <x-wirekit::stack gap="md" class="min-w-0">
            <x-wirekit::heading :level="3" size="lg">Review your order</x-wirekit::heading>
            <x-wirekit::card variant="outline"><x-wirekit::card.body><x-wirekit::stack gap="xs"><x-wirekit::text weight="medium">Shipping address</x-wirekit::text><x-wirekit::text variant="muted">Ada Lovelace · 12 Analytical Way · London EC1 · United Kingdom</x-wirekit::text></x-wirekit::stack></x-wirekit::card.body></x-wirekit::card>
            <x-wirekit::card variant="outline"><x-wirekit::card.body><x-wirekit::stack gap="xs"><x-wirekit::text weight="medium">Payment method</x-wirekit::text><x-wirekit::text variant="muted">Visa ending 4242 · expires 09/28</x-wirekit::text></x-wirekit::stack></x-wirekit::card.body></x-wirekit::card>
            <x-wirekit::card variant="outline"><x-wirekit::card.body><x-wirekit::stack gap="xs"><x-wirekit::text weight="medium">Delivery</x-wirekit::text><x-wirekit::text variant="muted">Standard (3–5 business days). Tracking emailed once your parcel leaves the warehouse.</x-wirekit::text></x-wirekit::stack></x-wirekit::card.body></x-wirekit::card>
            <x-wirekit::card variant="outline"><x-wirekit::card.body><x-wirekit::stack gap="xs"><x-wirekit::text weight="medium">Gift options</x-wirekit::text><x-wirekit::text variant="muted">Add a gift message and recyclable wrap at no extra cost.</x-wirekit::text></x-wirekit::stack></x-wirekit::card.body></x-wirekit::card>
            <x-wirekit::card variant="outline"><x-wirekit::card.body><x-wirekit::stack gap="xs"><x-wirekit::text weight="medium">Order notes</x-wirekit::text><x-wirekit::text variant="muted">Leave with the concierge if no one answers. Ring twice on arrival.</x-wirekit::text></x-wirekit::stack></x-wirekit::card.body></x-wirekit::card>
        </x-wirekit::stack>
        <x-wirekit::sticky-panel offset="0" width="15rem" label="Order summary">
            <x-slot:header>
                <x-wirekit::heading :level="3" size="md">Summary</x-wirekit::heading>
            </x-slot:header>
            <x-wirekit::stack gap="sm">
                <x-wirekit::row justify="between"><x-wirekit::text variant="muted">Subtotal</x-wirekit::text><x-wirekit::text>€349</x-wirekit::text></x-wirekit::row>
                <x-wirekit::row justify="between"><x-wirekit::text variant="muted">Shipping</x-wirekit::text><x-wirekit::text>€5</x-wirekit::text></x-wirekit::row>
                <x-wirekit::row justify="between"><x-wirekit::text weight="semibold">Total</x-wirekit::text><x-wirekit::text weight="semibold">€354</x-wirekit::text></x-wirekit::row>
            </x-wirekit::stack>
            <x-slot:footer>
                <x-wirekit::button class="w-full">Place order</x-wirekit::button>
            </x-slot:footer>
        </x-wirekit::sticky-panel>
    </x-wirekit::grid>
</x-wirekit::scroll-area>
:::

```blade
<div style="display: flex; gap: 2rem; align-items: flex-start;">
    <article style="flex: 1 1 auto;">
        {{-- your long-form content --}}
    </article>

    <x-wirekit::sticky-panel offset="2rem" width="20rem" label="Filters">
        <x-slot:header>
            <x-wirekit::heading :level="3" size="md">Filters</x-wirekit::heading>
        </x-slot:header>
        {{-- filter controls (scrollable) --}}
        <x-slot:footer>
            <x-wirekit::button surface="outline" class="w-full">Reset</x-wirekit::button>
        </x-slot:footer>
    </x-wirekit::sticky-panel>
</div>
```

::: warning
`position: sticky` sticks relative to its **nearest scrolling ancestor**, not always the viewport. An ancestor with `overflow: auto`/`scroll` becomes that scroll context — the demo above relies on exactly this (the panel sticks inside the fixed-height scroll frame). The gotcha is an **unintended** one: if a wrapper you didn't mean to scroll sets `overflow: hidden`/`auto`/`scroll`, the panel silently sticks (or clips) there instead of where you expect, and `overflow: hidden` with no scroll room leaves it nowhere to stick. If your panel won't stick, check the ancestor chain for an unintended overflow.
:::

## Header-only / Footer-only

Both slots are optional — omit either and the layout collapses cleanly (the body always fills the remaining height).

:::preview{title="Body + footer, no header"}
<x-wirekit::sticky-panel max-height="13rem" width="18rem" label="Notification settings">
    <x-wirekit::stack gap="sm">
        <x-wirekit::text variant="muted" size="sm">A settings list taller than the panel — the body scrolls while the save action stays pinned below it.</x-wirekit::text>
        <x-wirekit::checkbox name="np-mentions" label="Mentions" checked />
        <x-wirekit::checkbox name="np-replies" label="Replies to my posts" checked />
        <x-wirekit::checkbox name="np-follows" label="New followers" />
        <x-wirekit::checkbox name="np-digest" label="Weekly digest email" checked />
        <x-wirekit::checkbox name="np-billing" label="Billing alerts" checked />
        <x-wirekit::checkbox name="np-product" label="Product announcements" />
        <x-wirekit::checkbox name="np-security" label="Security notifications" checked />
        <x-wirekit::checkbox name="np-tips" label="Tips and tutorials" />
    </x-wirekit::stack>
    <x-slot:footer>
        <x-wirekit::button class="w-full">Save preferences</x-wirekit::button>
    </x-slot:footer>
</x-wirekit::sticky-panel>
:::

## Mobile sheet & collapse patterns

The panel keeps a single responsibility — a pinned, scrollable companion column. Two common "extras" are handled by **composing** an existing component rather than configuring this one, which keeps the markup explicit and the a11y model correct:

- **Bottom sheet on mobile** — set `mobileBehavior="hide"` so the panel disappears below the breakpoint, then render a [`<x-wirekit::drawer position="bottom">`](drawer.md) holding the same content for small screens. The drawer already provides the focus trap, backdrop, and dialog semantics a bottom sheet needs.
- **Auto-collapse a tall panel** — wrap the body content in a [`<x-wirekit::collapsible>`](collapsible.md) so the reader can fold it down when an article runs much longer than the panel. The collapsible carries its own `aria-expanded` button and keyboard model.

The auto-collapse pattern composes directly inside the body slot — group the panel's sections into `<x-wirekit::collapsible>`s so a tall companion column folds down to just the parts the reader wants:

:::preview{title="Auto-collapse sections inside a sticky panel"}
<x-wirekit::sticky-panel max-height="18rem" width="22rem" label="Article tools">
    <x-wirekit::stack gap="md">
        <x-wirekit::collapsible trigger="On this page" :open="true">
            <x-wirekit::stack gap="xs">
                <x-wirekit::text size="sm" variant="muted">Introduction</x-wirekit::text>
                <x-wirekit::text size="sm" variant="muted">Getting started</x-wirekit::text>
                <x-wirekit::text size="sm" variant="muted">Advanced usage</x-wirekit::text>
            </x-wirekit::stack>
        </x-wirekit::collapsible>
        <x-wirekit::collapsible trigger="Related articles">
            <x-wirekit::stack gap="xs">
                <x-wirekit::text size="sm" variant="muted">Theming guide</x-wirekit::text>
                <x-wirekit::text size="sm" variant="muted">Component reference</x-wirekit::text>
            </x-wirekit::stack>
        </x-wirekit::collapsible>
    </x-wirekit::stack>
</x-wirekit::sticky-panel>
:::

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `offset` | string | `'2rem'` | Distance from the viewport top when pinned (any CSS length). Also drives the default max-height. |
| `maxHeight` | string\|null | `null` | Explicit max-height override; default is `calc(100dvh - 2 × offset)`. |
| `width` | string | `'20rem'` | Panel width on desktop (capped at `100%`). |
| `hideBelow` | string | `'md'` | Breakpoint (`sm`/`md`/`lg`/`xl`) at/above which the panel sticks. |
| `mobileBehavior` | string | `'inline'` | Below the breakpoint: `inline` (un-stick, render in flow) or `hide` (remove). |
| `scrollShadow` | bool | `true` | Show top/bottom shadows when the body overflows. They render as overlays **above** the content (so a hovered row never covers them) and auto-hide at the scroll extremes. Set `false` to disable. |
| `label` | string\|null | `null` | Accessible name for the panel + its scrollable body region. |
| `scope` | string\|null | `null` | Scoped personalization name. |

## Slots

| Slot | Description |
| --- | --- |
| Default | The scrollable body region |
| `header` | Non-scrolling header pinned to the top of the panel |
| `footer` | Non-scrolling footer pinned to the bottom of the panel |

## Accessibility

- Renders as a semantic `<aside>` (a `complementary` landmark); pass `label` to give it an accessible name.
- The scrollable body is a keyboard-reachable region (`tabindex="0"`, `role="region"`, an `aria-label`, and a visible focus ring) so keyboard and switch users can scroll it — WCAG 2.1.1.
- `overscroll-behavior: contain` on the body stops scroll-chaining from jumping the whole page when the body reaches its scroll end.
- Tab order follows DOM order: header → body → footer.

## Keyboard Interaction

| Key | Action |
| --- | --- |
| `Tab` | Move focus through the header, the body region, then the footer (DOM order) |
| `Arrow keys` / `Page Up` / `Page Down` | Scroll the body region when it has keyboard focus |

## Design Tokens

| Token | Used for |
| --- | --- |
| `--color-wk-bg-elevated` | Panel surface |
| `--color-wk-border` | Panel + header/footer dividers |
| `--radius-wk-lg` | Panel corner radius |
| `--shadow-wk-md` | Panel elevation |
| `--color-wk-ring` | Body-region focus ring |
| `--padding-wk-x-md` / `--padding-wk-y-md` | Header / body / footer padding |

## Usage & Conventions

::: info
**Composition.** The panel is the `<aside>`; place it in your own two-column layout (a flex row with the article and the panel). The panel supplies the sticky behavior, the bounded height, and the scrollable body — your layout supplies the columns and the gutter.
:::

## Further Reading

- [MDN: `position: sticky`](https://developer.mozilla.org/en-US/docs/Web/CSS/position#sticky_positioning)
- [MDN: `overscroll-behavior`](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior)
- [web.dev: The large, small, and dynamic viewport units](https://web.dev/blog/viewport-units)
