컴포넌트
데모 임베드
글 안의 라이브 데모예요. 가까이 스크롤됐을 때만 iframe을 올려요.
- #arc
- #component
- #embed
- #iframe
- #demo
- #blog
- #lazy
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/demo-embed.json파일
components/arc/demo-embed/demo-embed.module.csscomponents/arc/demo-embed/demo-embed.tsx
함께 오는 항목
demo-embed.module.cssdemo-embed.module.cssdemo-embed.module.csscss
.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.tsxdemo-embed.tsxdemo-embed.tsxtsx
"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;