Autumn Collection
Doors open at 10:00. Coffee is on the house until the first talk starts.
An edit surface beside a live preview, each scrolling on its own, collapsing to a tab switch when the panel is too narrow to hold both. The split is a container query on the panel's own width, not a viewport breakpoint.
Two slots, edit and preview. The examples on this page run in fill mode at a fixed height so they stay inside the docs; a real page usually leaves mode at sticky and lets the document scroll.
Doors open at 10:00. Coffee is on the house until the first talk starts.
The admin sidebar is 18rem open and 3rem collapsed, a 240px swing that is wider than a Tailwind breakpoint step. A viewport query cannot see it, so at 1440px the same page would be comfortable with the sidebar collapsed and cramped with it open. The threshold is measured on the panel instead: 5xl (1024px) by default, with 4xl (896px) and 6xl (1152px) available. 4xl would split at roughly 448px per pane with the sidebar open, too narrow for a form beside a preview; 6xl lands on exactly 1440px with the sidebar expanded, the most common laptop width here, so rounding and zoom would make it flap.
<PreviewPanel threshold="6xl" ratio="3:2" side="left">
<template #edit>…</template>
<template #preview>…</template>
</PreviewPanel>Two ways the panel gets its height, and the choice decides which pane scrolls.
09:00 — Registration
09:05 — Keynote
09:10 — Break
09:15 — Panel
09:20 — Workshops
09:25 — Closing
09:30 — Registration
09:35 — Keynote
09:40 — Break
09:45 — Panel
09:50 — Workshops
09:55 — Closing
The panel never references a navbar token. The page passes its own offsets in, which is what lets the same component serve three repos with three different headers.
<PreviewPanel
offset="calc(var(--navbar-height-desktop) + 1rem)"
inset-bottom="1rem"
>In sticky mode, whatever follows the panel in the document must be no taller than insetBottom. A sticky element stops sticking once its containing block runs out, so with pb-16 on the page and a 1rem insetBottom, scrolling to the very bottom shoves the pinned preview 3rem up and its top disappears under the page header. Pad the inside of the edit slot instead: that height belongs to the containing block and costs nothing. There is no fixing this from inside the component, because the offending padding lives on an ancestor it cannot see.
- <div class="pb-16">
- <PreviewPanel>…</PreviewPanel>
- </div>
+ <div>
+ <PreviewPanel>
+ <template #edit><div class="pb-16">…</div></template>
+ </PreviewPanel>
+ </div>The preview is framed in a card with an optional chrome row: a title, a reload button, and an expand button. Reload remounts the preview subtree for a tick, because a :key bump on a wrapper is not enough when the slot's vnodes belong to the consumer.
Reloads: 0. The reload button drops the preview subtree for a tick and mounts it fresh, so a preview that reads data in setup runs again.
https://example.com/programs
surface=false drops the border, background and rounding and renders the slot straight into the scroller, for a preview that draws its own page.
No card, no chrome row. The preview slot renders straight into the scroller and draws its own page.
Poster
A preview that owns its background
A footer slot spans both columns and sticks to the bottom, so a long edit pane never scrolls Save off-screen. It is a fixed h-14 so the pinned preview can subtract a real number instead of guessing, and the panel adds that height to its own bottom inset automatically.
Draft · saved 2 minutes ago
Below the threshold a floating pill switches panes. It is plain position: fixed, like every other pane switcher in the app, and the container query that hides it lives on a static wrapper rather than the pill itself. Use PreviewPanelSwitcher on its own inside any Tabs when a page wants the same control somewhere else, and pass hideSwitcher to the panel so there are not two.
container-type: inline-size does NOT make the panel a containing block for position: fixed descendants. That was assumed here at first and measured to be wrong: a probe anchored to the viewport and the computed contain read none. Only an explicit contain: layout or paint would capture them. Two consequences are baked into this folder. The floating switcher is plain fixed. And the expanded preview teleports to body rather than switching to fixed, so component state survives the move even though the inner scroll position does not.
Import all parts and piece them together.
<script setup>
import {
PreviewPanel,
PreviewPanelPane (edit),
PreviewPanelPane (preview),
PreviewPanelSurface,
PreviewPanelSwitcher,
} from "@/components/ui/preview-panel";
</script>
<template>
<PreviewPanel>
<PreviewPanelPane (edit) />
<PreviewPanelPane (preview)>
<PreviewPanelSurface />
</PreviewPanelPane (preview)>
<PreviewPanelSwitcher />
</PreviewPanel>
</template>| Prop | Type | Default | Description |
|---|---|---|---|
tab | "edit" | "preview" | undefined | Controlled active pane. Meaningless once the panes are side by side. |
defaultTab | "edit" | "preview" | "edit" | Active pane when uncontrolled. |
threshold | "4xl" | "5xl" | "6xl" | "5xl" | Panel width at which the panes split: 896, 1024 or 1152px. |
ratio | "1:1" | "3:2" | "2:1" | "2:3" | "1:1" | flex-grow factors for edit and preview once split. Applied inline, so ratios do not multiply against the threshold classes. |
side | "left" | "right" | "right" | Which side the preview lands on once split. |
mode | "sticky" | "fill" | "sticky" | How the panel gets its height. See Sticky and fill. |
offset | string | "0px" | CSS length: where the pinned preview starts below the viewport top. Also the scroll-margin the tab-change scroll lands against. |
insetBottom | string | "1rem" | Gap the pinned preview keeps clear of the bottom. A footer adds 3.5rem to it automatically. |
editLabel | string | "Edit" | Label on the switcher's first tab. |
previewLabel | string | "Preview" | Label on the switcher's second tab. |
previewTitle | string | undefined | Chrome-row label on the preview card. Usually the public URL it stands in for. |
surface | boolean | true | Frame the preview in a card. Off renders the slot bare in a scroller. |
padded | boolean | false | Inner padding on the preview body. Off when the preview draws its own page. |
reloadable | boolean | false | Show a reload button that remounts the preview subtree. |
expandable | boolean | false | Show an expand button. Expanding teleports the card to body; Escape or a backdrop click collapses it. |
fade | boolean | false | Gradient edge fades on the preview body. Only legible on a surface that owns a background. |
clientOnlyPreview | boolean | false | Wrap the preview in ClientOnly, for trees that hydrate badly (deep reka trees drift on useId). |
hideSwitcher | boolean | false | The page renders its own pane switcher somewhere else. |
class | string | undefined | Classes for the panel root. |
| Event | Description |
|---|---|
update:tab | Fires when the active pane changes. Enables v-model:tab. |
reload | Fires when the reload button is pressed, after the preview has been keyed to remount. |
| Slot | Description |
|---|---|
edit | The edit surface. |
preview | The live preview. |
preview-toolbar | Extra buttons in the preview chrome row, left of reload and expand. |
preview-fallback | Shown while clientOnlyPreview is hydrating. Defaults to a pulsing block. |
switcher | Replaces the floating pill, still inside the rail that hides it above the threshold. |
footer | A sticky h-14 bar spanning both columns. |
| Prop | Type | Default | Description |
|---|---|---|---|
value | "edit" | "preview" | — | Which pane this is. Required. Maps to the reka TabsContent value. |
grow | number | 1 | flex-grow factor once the panes sit side by side. |
scroll | boolean | true | Own the scroller. Set false when the pane's child already scrolls itself, as the preview surface does. |
gutter | boolean | false | Bleed a 4px gutter outside and pad it back inside, so a focus ring on a full-width input does not trip a horizontal scrollbar. |
class / scrollClass | string | undefined | Classes for the pane and for its inner scroller. |
| Prop | Type | Default | Description |
|---|---|---|---|
bare | boolean | false | Drop the border, background and rounding. Just the scroller. |
title | string | undefined | Chrome-row label. |
reloadable | boolean | false | Show the reload button. Uses the panel's reload when inside one, its own counter otherwise. |
expandable | boolean | false | Show the expand button. |
padded | boolean | false | Inner padding on the body. |
clientOnly | boolean | false | Wrap the body in ClientOnly. |
fade | boolean | false | Gradient edge fades. Ignored when bare, since there is no surface colour to fade from. |
class | string | undefined | Classes for the surface. |
| Slot | Description |
|---|---|
default | The preview body. |
toolbar | Extra chrome-row buttons. |
fallback | ClientOnly fallback. |
| Prop | Type | Default | Description |
|---|---|---|---|
items | { value: string, label: string, icon?: string }[] | — | The tabs. Required. Reads reka's Tabs context from any ancestor TabsRoot, so it works inside a PreviewPanel or inside a page that owns its own tabs. |
size | "sm" | "md" | "md" | Pill height and label size. |
class | string | undefined | Classes for the pill. |
Keyboard shortcuts and ARIA behavior.
| Shortcut | Description |
|---|---|
| Tab | Both panes are always in the DOM under force-mount, so tab order reaches the hidden pane's content only when it is on screen. |
| ← + → | On the switcher, moves between Edit and Preview. |
| Esc | Collapses an expanded preview. |