preview-panel
A pan-zoom container with controls, fullscreen dialog, and synced state between views.
About
- Pan & zoom via drag, scroll wheel, and pinch-to-zoom gestures, behind a lock so the page scrolls past the panel until the user unlocks it
- An
actionsslot for your own buttons beside the zoom controls - Built-in zoom controls with percentage badge
- Zero re-renders during continuous interactions (ref-based transforms)
unsafeHtmlprop for rendering raw HTML/SVG content directly (caller-sanitized; XSS sink)- Fullscreen dialog variant with
PreviewPanelDialog - Synced state between inline and dialog panels via
syncPanels - RAF-batched state emission for maximum performance
- Built with gentleduck/ui components:
Button,Badge,ButtonGroup,Separator,Tooltip,Dialog
Philosophy
Preview panels bridge the gap between list views and detail views. Instead of navigating away to see content, users get an inline preview that respects their current context. We ship this as a compound component because the trigger, content, and dialog modes each need independent control while sharing state.
How It's Built
Installation
npx @gentleduck/cli add preview-panel
npx @gentleduck/cli add preview-panel
Usage
import { PreviewPanel } from '@/components/ui/preview-panel'
<PreviewPanel maxHeight="400px">
<YourContent />
</PreviewPanel>
Locking
Drag, wheel and pinch start locked, so scrolling a page past the panel scrolls the page. The lock button in the controls turns them on, and the zoom buttons work either way. Start unlocked where the panel is the page's main content:
<PreviewPanel defaultLocked={false} maxHeight="400px">
<YourContent />
</PreviewPanel>
With actions
actions renders in the toolbar before the zoom controls, and still renders with showControls={false}.
<PreviewPanel actions={<Button size="icon-sm" icon={<Copy />} aria-label="Copy" />}>
<YourContent />
</PreviewPanel>
With HTML content
<PreviewPanel unsafeHtml="<svg>...</svg>" maxHeight="400px" />
With dialog and sync
import { PreviewPanelDialog } from '@/components/ui/preview-panel'
<PreviewPanelDialog syncPanels maxHeight="400px">
<YourContent />
</PreviewPanelDialog>
Examples
HTML / SVG content
Render raw HTML or SVG strings directly using the unsafeHtml prop. The value is rendered verbatim via dangerouslySetInnerHTML and is not sanitized, so only pass HTML you fully trust.
Dialog with synced zoom
Open the content in a fullscreen dialog. Zoom and position sync between both panels, and the fullscreen panel starts unlocked.
Image viewer
Use as a zoomable image viewer.
RTL Support
Set the direction once with DirectionProvider (or <html dir="rtl">) and the panel follows: the zoom controls move to the top-left corner and lay out RTL. Use zoomInText, zoomOutText, resetText, lockText, and unlockText to translate the control labels.
Motion
Use MotionPreviewPanel for a smooth entrance animation powered by motion. The panel uses a scaleIn entrance (fade, scale, and blur) with a bouncy spring transition on mount, and its internal controls (buttons, separators, badges, tooltips) use the full motion stack.
motion package. Use MotionPreviewPanel instead of PreviewPanel. Same props. The internal zoom controls automatically use MotionButton, MotionBadge, MotionSeparator, and MotionTooltip.API Reference
PreviewPanel
The core pan-zoom container.
| Prop | Type | Default | Description |
|---|---|---|---|
dir | 'ltr' | 'rtl' | - | Direction override. Defaults to the nearest DirectionProvider, then <html dir>. |
maxHeight | string | -- | Maximum height of the panel container (e.g. "400px") |
minZoom | number | 0.25 | Minimum zoom level |
maxZoom | number | 4 | Maximum zoom level |
initialZoom | number | 1 | Starting zoom level |
showControls | boolean | true | Whether to show the zoom controls overlay |
defaultLocked | boolean | true | Whether drag, wheel and pinch start locked, so the wheel scrolls the page. The zoom buttons work either way |
actions | React.ReactNode | -- | Rendered in the toolbar before the zoom controls |
unsafeHtml | string | -- | Raw HTML string rendered verbatim via dangerouslySetInnerHTML. Takes priority over children. Not sanitized: pass only HTML you fully trust, never user-, CMS-, or URL-derived markup |
children | React.ReactNode | -- | Content to render inside the panel |
className | string | -- | Additional CSS classes for the outer container |
style | React.CSSProperties | -- | Inline styles for the outer container |
onStateChange | (state: PreviewPanelState) => void | -- | Called whenever zoom or position changes. Use to sync with another panel |
syncState | PreviewPanelState | -- | External state to apply. The panel syncs to this state when set |
zoomInText | string | 'Zoom in' | Label and tooltip for the zoom-in control |
zoomOutText | string | 'Zoom out' | Label and tooltip for the zoom-out control |
resetText | string | 'Reset view' | Label and tooltip for the reset control |
lockText | string | 'Lock the view' | Label and tooltip for the lock control while the view is unlocked |
unlockText | string | 'Unlock the view' | Label and tooltip for the lock control while the view is locked |
...props | React.HTMLProps<HTMLDivElement> | - | Additional props to spread to the content div |
MotionPreviewPanel
scaleIn entrance (fade, scale, and blur) with springBouncy transition on mount. Zoom controls use MotionButton, MotionBadge, MotionSeparator, and MotionTooltip internally. Requires the motion package.
| Prop | Type | Default | Description |
|---|---|---|---|
...props | PreviewPanelProps | - | All props from PreviewPanel are supported |
PreviewPanelDialog
Combines an inline PreviewPanel with a fullscreen Dialog. The expand button sits in the inline panel's toolbar, before the zoom controls. The inline panel starts locked and the fullscreen one unlocked.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - | Content rendered in both inline and dialog panels |
unsafeHtml | string | - | Raw HTML string rendered verbatim via dangerouslySetInnerHTML. Takes priority over children. Not sanitized: pass only HTML you fully trust |
className | string | - | Class name for the outer wrapper |
panelClassName | string | - | Class name applied to both PreviewPanel instances |
maxHeight | string | - | Maximum height of the inline panel |
minZoom | number | 0.25 | Minimum zoom level |
maxZoom | number | 4 | Maximum zoom level |
initialZoom | number | 1 | Starting zoom level |
showControls | boolean | true | Whether to show zoom controls |
defaultLocked | boolean | true | Whether the inline panel starts locked. The fullscreen panel starts unlocked |
syncPanels | boolean | true | Whether to sync zoom and position between inline and dialog panels |
fullscreenText | string | 'Open fullscreen' | Label and tooltip for the expand button |
zoomInText | string | 'Zoom in' | Label and tooltip for the zoom-in control |
zoomOutText | string | 'Zoom out' | Label and tooltip for the zoom-out control |
resetText | string | 'Reset view' | Label and tooltip for the reset control |
lockText | string | 'Lock the view' | Label and tooltip for the lock control while the view is unlocked |
unlockText | string | 'Unlock the view' | Label and tooltip for the lock control while the view is locked |
PreviewPanelState
The state object used for syncing between panels.
type PreviewPanelState = {
zoom: number
x: number
y: number
}