# 데모 임베드

글 안의 라이브 데모예요. 가까이 스크롤됐을 때만 iframe을 올려요.

- Type: component
- Theme: base
- Tags: arc, component, embed, iframe, demo, blog, lazy
- English title: Demo embed
- URL: https://dsui.vercel.app/items/demo-embed

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| title | text | 제목 | 앱 헤더 데모 |
| linkLabel | text | 링크 문구 | 열어 보기 |

## Source

### demo-embed.module.css

```css
.embed { display: flex; margin: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); }
/* A fixed ratio, so the still and the frame that replaces it are the same size and the page does not jump. */
.stage { position: relative; aspect-ratio: 16 / 9; background: var(--surface-sunken); }
.media { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; object-fit: cover; }
.placeholder { position: absolute; inset: 0; background: var(--surface-sunken); }
.caption { display: flex; align-items: center; gap: var(--space-3); border-block-start: 1px solid var(--border-subtle); padding: var(--space-3) var(--space-4); }
.title { min-width: 0; flex: 1 1 auto; overflow: hidden; color: var(--text-secondary); font-size: var(--text-sm); text-overflow: ellipsis; white-space: nowrap; }
.link { display: inline-flex; flex: none; align-items: center; gap: var(--space-1); color: var(--accent); font-size: var(--text-sm); font-weight: 500; text-decoration: none; transition: color var(--duration-fast) var(--ease-standard); }
.link svg { flex: none; }
@media (hover: hover) and (pointer: fine) { .link:hover { color: var(--accent-strong); text-decoration: underline; text-underline-offset: 3px; } }
@media (prefers-reduced-motion: reduce) { .link { transition: none; } }

```

### demo-embed.tsx

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { UiIcon } from "../theme/ui-icon";
import styles from "./demo-embed.module.css";

export interface DemoEmbedProps {
  /** The page the frame loads once it comes near the window. */
  src: string;
  /** The frame's title, and the caption's text. An iframe without one is unreachable by keyboard navigation lists. */
  title: string;
  /** A still shown until then. */
  thumbnail?: string;
  /** Opens the demo on its own page. */
  href?: string;
  linkLabel?: string;
  /** How far ahead of the window to start loading. */
  rootMargin?: string;
  /** Replaces the caption's text side. */
  caption?: ReactNode;
  className?: string;
}

/**
 * A live demo inside an article. The still comes first and the frame is mounted only once it scrolls near the
 * window: an embed is a whole second page, and a post with several of them would otherwise load all of them before
 * the reader has seen the first paragraph.
 */
export function DemoEmbed({ src, title, thumbnail, href, linkLabel = "열어 보기", rootMargin = "200px", caption, className }: DemoEmbedProps) {
  const stage = useRef<HTMLDivElement>(null);
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    const el = stage.current;
    if (!el) return;
    // No IntersectionObserver (an old browser, a test runner): load it rather than never showing the demo at all.
    // After this paint, not during it — the server has rendered the still, and swapping it in the effect body
    // would both fight hydration and cascade a second render.
    if (typeof IntersectionObserver === "undefined") {
      const id = setTimeout(() => setMounted(true), 0);
      return () => clearTimeout(id);
    }
    const observer = new IntersectionObserver(
      entries => { if (entries.some(e => e.isIntersecting)) { setMounted(true); observer.disconnect(); } },
      { rootMargin },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [rootMargin]);

  return (
    <figure className={[styles.embed, className].filter(Boolean).join(" ")}>
      <div ref={stage} className={styles.stage}>
        {mounted ? (
          <iframe src={src} title={title} loading="lazy" className={styles.media} />
        ) : thumbnail ? (
          /* eslint-disable-next-line @next/next/no-img-element -- a generated still that may be missing; next/image cannot report that. */
          <img src={thumbnail} alt="" loading="lazy" className={styles.media} />
        ) : (
          <span className={styles.placeholder} aria-hidden="true" />
        )}
      </div>
      <figcaption className={styles.caption}>
        <span className={styles.title}>{caption ?? title}</span>
        {href ? (
          <a href={href} className={styles.link}>
            {linkLabel}
            <UiIcon iconRole="arrow-right" size={14} strokeWidth={1.75} aria-hidden="true" />
          </a>
        ) : null}
      </figcaption>
    </figure>
  );
}

DemoEmbed.displayName = "DemoEmbed";

export default DemoEmbed;

```
