Skip to content
Block

Demo frame

A resizable preview stage with device presets and a drag handle.

  • #arc
  • #block
  • #preview
  • #responsive
  • #frame
  • #resize
  • #demo

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/demo-frame.json

demo-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.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