Toggle Sidebar B
AppearanceLight & dark mode D

Preview Panel

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.

Default

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.

Replay preview

Autumn Collection

Doors open at 10:00. Coffee is on the house until the first talk starts.

Why a container query

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.

vue
<PreviewPanel threshold="6xl" ratio="3:2" side="left">
  <template #edit>…</template>
  <template #preview>…</template>
</PreviewPanel>

Sticky and fill

Two ways the panel gets its height, and the choice decides which pane scrolls.

  • sticky (default) — the page scrolls the way it always does and the edit pane goes with it, no inner scroller and no card of its own. Only the preview pins, offset below the viewport top and insetBottom clear of its bottom. Its travel comes from the edit pane being taller than it.
  • fill — the parent hands down a definite height and both panes scroll inside it. For surfaces that own their viewport, like a dialog.
Replay preview
  1. 01 · Registration
  2. 02 · Keynote
  3. 03 · Break
  4. 04 · Panel
  5. 05 · Workshops
  6. 06 · Closing
  7. 07 · Registration
  8. 08 · Keynote
  9. 09 · Break
  10. 10 · Panel
  11. 11 · Workshops
  12. 12 · Closing

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

Offsets

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.

vue
<PreviewPanel
  offset="calc(var(--navbar-height-desktop) + 1rem)"
  inset-bottom="1rem"
>

Host invariant

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.

diff
- <div class="pb-16">
-   <PreviewPanel>…</PreviewPanel>
- </div>
+ <div>
+   <PreviewPanel>
+     <template #edit><div class="pb-16">…</div></template>
+   </PreviewPanel>
+ </div>

Preview surface

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.

Replay preview

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

Bare preview

surface=false drops the border, background and rounding and renders the slot straight into the scroller, for a preview that draws its own page.

Replay preview

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

Switcher

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.

Replay preview
The edit pane would be here.

A trap worth knowing

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.

Anatomy

Import all parts and piece them together.

vue
<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>

API Reference

PreviewPanel

PropTypeDefaultDescription
tab"edit" | "preview"undefinedControlled 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.
offsetstring"0px"CSS length: where the pinned preview starts below the viewport top. Also the scroll-margin the tab-change scroll lands against.
insetBottomstring"1rem"Gap the pinned preview keeps clear of the bottom. A footer adds 3.5rem to it automatically.
editLabelstring"Edit"Label on the switcher's first tab.
previewLabelstring"Preview"Label on the switcher's second tab.
previewTitlestringundefinedChrome-row label on the preview card. Usually the public URL it stands in for.
surfacebooleantrueFrame the preview in a card. Off renders the slot bare in a scroller.
paddedbooleanfalseInner padding on the preview body. Off when the preview draws its own page.
reloadablebooleanfalseShow a reload button that remounts the preview subtree.
expandablebooleanfalseShow an expand button. Expanding teleports the card to body; Escape or a backdrop click collapses it.
fadebooleanfalseGradient edge fades on the preview body. Only legible on a surface that owns a background.
clientOnlyPreviewbooleanfalseWrap the preview in ClientOnly, for trees that hydrate badly (deep reka trees drift on useId).
hideSwitcherbooleanfalseThe page renders its own pane switcher somewhere else.
classstringundefinedClasses for the panel root.
EventDescription
update:tabFires when the active pane changes. Enables v-model:tab.
reloadFires when the reload button is pressed, after the preview has been keyed to remount.
SlotDescription
editThe edit surface.
previewThe live preview.
preview-toolbarExtra buttons in the preview chrome row, left of reload and expand.
preview-fallbackShown while clientOnlyPreview is hydrating. Defaults to a pulsing block.
switcherReplaces the floating pill, still inside the rail that hides it above the threshold.
footerA sticky h-14 bar spanning both columns.

PreviewPanelPane

PropTypeDefaultDescription
value"edit" | "preview"Which pane this is. Required. Maps to the reka TabsContent value.
grownumber1flex-grow factor once the panes sit side by side.
scrollbooleantrueOwn the scroller. Set false when the pane's child already scrolls itself, as the preview surface does.
gutterbooleanfalseBleed 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 / scrollClassstringundefinedClasses for the pane and for its inner scroller.

PreviewPanelSurface

PropTypeDefaultDescription
barebooleanfalseDrop the border, background and rounding. Just the scroller.
titlestringundefinedChrome-row label.
reloadablebooleanfalseShow the reload button. Uses the panel's reload when inside one, its own counter otherwise.
expandablebooleanfalseShow the expand button.
paddedbooleanfalseInner padding on the body.
clientOnlybooleanfalseWrap the body in ClientOnly.
fadebooleanfalseGradient edge fades. Ignored when bare, since there is no surface colour to fade from.
classstringundefinedClasses for the surface.
SlotDescription
defaultThe preview body.
toolbarExtra chrome-row buttons.
fallbackClientOnly fallback.

PreviewPanelSwitcher

PropTypeDefaultDescription
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.
classstringundefinedClasses for the pill.

Accessibility

Keyboard shortcuts and ARIA behavior.

Shortcut Description
TabBoth 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.
EscCollapses an expanded preview.
  • The panes are reka TabsContent under force-mount, so the stacked/split decision is authored entirely in CSS and there is no hydration flash.
  • Switching tabs while stacked scrolls the panel back to its own top, because only one pane is rendered and the two are nowhere near the same height. Split mode is left alone. The flex-direction is read from the DOM rather than recomputing the threshold in JS, so the container query stays the one source of truth.
  • The switcher hides with invisible, not hidden: reka's TabsIndicator measures the active trigger, and a display:none tablist measures zero, so collapsing the sidebar would animate a zero-width indicator out from the left.
  • Heights use svh. lvh overshoots by the mobile toolbar height on the tablets where the split is actually reachable, and dvh would relayout the whole split on every browser-chrome animation.