Block
Demo frame
A resizable preview stage with device presets and a drag handle.
- #arc
- #block
- #preview
- #responsive
- #frame
- #resize
- #demo
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/demo-frame.jsonFiles
components/arc/blocks/demo-frame/demo-frame.module.csscomponents/arc/blocks/demo-frame/demo-frame.tsx
Registry items it pulls in
Source
View as Markdowndemo-frame.module.cssdemo-frame.module.cssdemo-frame.module.csscss
.frame { display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); border-block-end: 1px solid var(--border-subtle); padding: var(--space-3) var(--space-4); }
.width { color: var(--text-secondary); font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.actions { display: flex; align-items: center; gap: var(--space-1); margin-inline-start: auto; }
.stage { position: relative; display: flex; justify-content: center; overflow: hidden; padding: var(--space-5); background: var(--surface-muted); }
/* No transition on width: it has to track the pointer exactly while dragging. */
.box { max-width: 100%; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); }
.handle { position: absolute; inset-block: var(--space-5); width: var(--space-2); border: 0; border-radius: var(--radius-pill); background: var(--border-strong); cursor: col-resize; touch-action: none; transition: background var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .handle:hover { background: var(--accent); } }
.stage[data-dragging] .handle { background: var(--accent); }
/* While dragging, the pointer must not select the page or slip onto the demo inside the frame. */
.stage[data-dragging] { user-select: none; }
.stage[data-dragging] .box { pointer-events: none; }
/* Below the smallest preset the handle has nowhere to go, so the frame simply fills the width. */
@media (max-width: 767px) {
.stage { padding: var(--space-3); }
.box { width: 100% !important; }
.handle { display: none; }
}
@media (prefers-reduced-motion: reduce) { .handle { transition: none; } }
demo-frame.tsxdemo-frame.tsxdemo-frame.tsxtsx
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import SegmentedControl from "../../segmented-control/segmented-control";
import { playUiSound } from "../../sound/play";
import styles from "./demo-frame.module.css";
export interface DevicePreset {
value: string;
label: string;
width: number;
}
export const DEVICE_PRESETS: DevicePreset[] = [
{ value: "mobile", label: "모바일", width: 390 },
{ value: "tablet", label: "태블릿", width: 834 },
{ value: "desktop", label: "데스크톱", width: 1280 },
];
export interface DemoFrameProps {
children: ReactNode;
presets?: DevicePreset[];
/** The width it opens at. Default: the last preset. */
defaultWidth?: number;
minWidth?: number;
maxWidth?: number;
height?: number;
/** Buttons at the end of the toolbar, such as a theme switch or a reload. */
actions?: ReactNode;
/** The accessible name of the toolbar. */
label?: string;
className?: string;
}
/**
* A resizable stage for a demo: device presets, the width in pixels, and a handle to drag anything in between.
* The width is the point, so it is written out in numbers and not only implied by the frame — a reader checking a
* breakpoint needs to know where they are.
*/
export function DemoFrame({
children,
presets = DEVICE_PRESETS,
defaultWidth,
minWidth = 320,
maxWidth = 1600,
height = 480,
actions,
label = "미리보기 조절",
className,
}: DemoFrameProps) {
const [width, setWidth] = useState(defaultWidth ?? presets[presets.length - 1]?.width ?? 1280);
const [dragging, setDragging] = useState(false);
const stage = useRef<HTMLDivElement>(null);
const clamp = useCallback((n: number) => Math.min(maxWidth, Math.max(minWidth, Math.round(n))), [minWidth, maxWidth]);
const active = presets.find(p => p.width === width)?.value ?? "";
useEffect(() => {
if (!dragging) return;
// The frame is centred, so the pointer moves each edge by half of what the width changes.
const move = (event: PointerEvent) => {
const box = stage.current?.getBoundingClientRect();
if (box) setWidth(clamp((event.clientX - box.left - box.width / 2) * 2));
};
const stop = () => setDragging(false);
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
}, [dragging, clamp]);
return (
<div className={[styles.frame, className].filter(Boolean).join(" ")}>
<div className={styles.toolbar} role="toolbar" aria-label={label}>
<SegmentedControl
options={presets.map(p => ({ value: p.value, label: p.label }))}
value={active}
onValueChange={value => {
const preset = presets.find(p => p.value === value);
if (preset) setWidth(preset.width);
}}
label="미리보기 폭"
/>
<span className={styles.width}>{width}px</span>
{actions ? <div className={styles.actions}>{actions}</div> : null}
</div>
<div ref={stage} className={styles.stage} data-dragging={dragging ? "" : undefined}>
<div className={styles.box} style={{ width, height }}>
{children}
</div>
<div
role="separator"
tabIndex={0}
aria-label="미리보기 폭 조절"
aria-orientation="vertical"
aria-valuenow={width}
aria-valuemin={minWidth}
aria-valuemax={maxWidth}
aria-valuetext={`${width}px`}
className={styles.handle}
style={{ insetInlineStart: `calc(50% + ${width / 2}px + var(--space-2))` }}
onPointerDown={event => { event.preventDefault(); playUiSound("tick"); setDragging(true); }}
onKeyDown={event => {
const step = event.shiftKey ? 100 : 10;
const next =
event.key === "ArrowRight" ? width + step :
event.key === "ArrowLeft" ? width - step :
event.key === "Home" ? minWidth :
event.key === "End" ? maxWidth : null;
if (next === null) return;
event.preventDefault();
setWidth(clamp(next));
}}
/>
</div>
</div>
);
}
DemoFrame.displayName = "DemoFrame";
export default DemoFrame;
Used items
- Segmented controlComponent
A row of mutually exclusive options with a highlight that moves to the chosen segment.