08풀스크린 토글
확장 버튼을 누르면 플레이어가 무대를 가득 채우며 커지고 모서리 아이콘이 축소 모양으로 바뀌며, 다시 누르거나 Escape 를 치면 돌아옵니다.
쓰임코드를 따라 치는 튜토리얼 영상을 크게 펴서 봐야 하는 자리
transformEscapearia-expanded

<main class="stage">
<figure class="fs is-demo" id="fs">
<div class="fs__screen">
<div class="fs__stripes" aria-hidden="true"></div>
<div class="fs__world" aria-hidden="true"><span class="fs__sun"></span><span class="fs__hill fs__hill--a"></span><span class="fs__hill fs__hill--b"></span></div>
<span class="fs__clock" id="clock">0:47</span>
<span class="fs__badge">3:00</span>
<span class="fs__hint" aria-hidden="true">Esc 로 나가기</span>
<div class="fs__bar">
<span class="fs__time"><b id="t-now">0:47</b> / 3:00</span>
<button class="fsb" type="button" id="fsb" aria-expanded="false" aria-label="전체화면">
<span class="fsb__ic fsb__ic--out" aria-hidden="true"><svg viewBox="0 0 256 256" width="17" height="17"><g fill="currentColor"><path d="M208 48v160H48V48Z" opacity=".2"/><path d="M216 48v40a8 8 0 0 1-16 0V56h-32a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8M88 200H56v-32a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16m120-40a8 8 0 0 0-8 8v32h-32a8 8 0 0 0 0 16h40a8 8 0 0 0 8-8v-40a8 8 0 0 0-8-8M88 40H48a8 8 0 0 0-8 8v40a8 8 0 0 0 16 0V56h32a8 8 0 0 0 0-16"/></g></svg></span>
<span class="fsb__ic fsb__ic--in" aria-hidden="true"><svg viewBox="0 0 256 256" width="17" height="17"><g fill="currentColor"><path d="M208 64v128a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V64a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M152 96V48a8 8 0 0 1 16 0v40h40a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8m-56 56H48a8 8 0 0 0 0 16h40v40a8 8 0 0 0 16 0v-48a8 8 0 0 0-8-8m112 0h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-40h40a8 8 0 0 0 0-16M96 40a8 8 0 0 0-8 8v40H48a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8"/></g></svg></span>
</button>
</div>
<span class="fs__fillwrap" aria-hidden="true"><span class="fs__fill" id="fill"></span></span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// ── 플레이어. 커지는 건 width/height 가 아니라 transform: scale() 하나다 ──
.fs {
position: relative;
box-sizing: border-box;
width: min(300px, 72vw);
margin: 0;
transform: scale(1);
transform-origin: center center;
transition: transform $duration $easing;
}
.fs__screen {
position: relative;
height: clamp(88px, 26vw, 124px);
border-radius: $r-card;
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .34), rgba(23, 20, 26, .92));
box-shadow: $shadow-raised;
transition: border-radius $duration $easing;
}
.fs.is-full { transform: scale(1.3); }
.fs.is-full .fs__screen { border-radius: 0; }
.fs__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(255, 255, 255, .13) 0 12px, transparent 12px 32px);
animation-name: fs-pan;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fs-pan { from { transform: translateX(0); } to { transform: translateX(-64px); } }
.fs__world { position: absolute; inset: 0; }
.fs__sun {
position: absolute; right: 20%; top: 16%;
width: 26px; height: 26px; border-radius: 50%;
background: rgba(255, 210, 63, .95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, .22);
}
.fs__hill { position: absolute; bottom: -46%; height: 80%; border-radius: 50%; }
.fs__hill--a { left: -12%; width: 70%; background: rgba(255, 255, 255, .16); }
.fs__hill--b { right: -16%; width: 60%; background: rgba(255, 255, 255, .1); }
.fs__clock,
.fs__badge {
position: absolute; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 700;
}
.fs__clock { left: $sp-2; }
.fs__badge { right: $sp-2; color: rgba(255, 255, 255, .85); font-weight: 600; }
// 전체화면일 때만 뜨는 안내 — 컷으로 갈아 끼운다
.fs__hint {
position: absolute; left: 50%; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(47, 109, 246, .92);
color: #fff;
font-size: 10px; line-height: 14px; font-weight: 700;
white-space: nowrap;
opacity: 0;
transform: translateX(-50%);
}
.fs.is-full .fs__hint { opacity: 1; }
.fs__bar {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; justify-content: space-between; gap: $sp-2;
padding: 0 $sp-2 $sp-3;
}
.fs__time {
color: $subject-cream; // 화면 하단 그라디언트가 중간 밝기(#696b79)를 지나 잉크는 3.46:1 — 크림이 두 시점 다 통과(4.57~8.4:1)
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 600;
text-shadow: 0 1px 2px rgba($subject-ink, .7);
}
.fs__time b { font-weight: 700; }
.fsb {
position: relative;
box-sizing: border-box;
width: 28px; height: 28px;
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
border: 0;
border-radius: $r-sm;
background: rgba(255, 255, 255, .16);
color: #fff;
cursor: pointer;
transition: background $duration $easing, transform $duration $easing;
}
.fsb:hover { background: rgba(255, 255, 255, .3); }
.fsb:active { transform: scale(.92); }
.fsb:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.fsb__ic {
position: absolute; inset: 0;
display: inline-flex; align-items: center; justify-content: center;
}
.fsb__ic--in { opacity: 0; }
.fs.is-full .fsb__ic--out { opacity: 0; }
.fs.is-full .fsb__ic--in { opacity: 1; }
.fs__fillwrap { position: absolute; left: 0; right: 0; bottom: 0; height: 4px; background: rgba(255, 255, 255, .26); }
.fs__fill {
display: block; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
transform: scaleX(.26);
}
// ── 자동 루프: 보통 → 전체화면 → 보통. 아이콘과 안내는 steps(1,end) 로 한 프레임에 갈아 끼운다 ──
.fs.is-demo {
animation-name: fs-zoom;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes fs-zoom {
0%, 16% { transform: scale(1); }
38%, 64% { transform: scale(1.3); }
86%, 100% { transform: scale(1); }
}
.fs.is-demo .fs__screen {
animation-name: fs-round;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes fs-round {
0%, 16% { border-radius: $r-card; }
38%, 64% { border-radius: 0; }
86%, 100% { border-radius: $r-card; }
}
.fs.is-demo .fsb__ic--out,
.fs.is-demo .fsb__ic--in,
.fs.is-demo .fs__hint {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fs.is-demo .fsb__ic--out { animation-name: fs-ic-out; }
.fs.is-demo .fsb__ic--in { animation-name: fs-ic-in; }
.fs.is-demo .fs__hint { animation-name: fs-hint; }
@keyframes fs-ic-out {
0%, 26% { opacity: 1; }
27%, 74% { opacity: 0; }
75%, 100% { opacity: 1; }
}
@keyframes fs-ic-in {
0%, 26% { opacity: 0; }
27%, 74% { opacity: 1; }
75%, 100% { opacity: 0; }
}
@keyframes fs-hint {
0%, 26% { opacity: 0; }
27%, 74% { opacity: 1; }
75%, 100% { opacity: 0; }
}
.fs.is-demo .fs__fill {
animation-name: fs-fill;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fs-fill {
0% { transform: scaleX(.26); }
86%, 100% { transform: scaleX(.62); }
}
@media (max-width: 340px) {
.fs__bar { padding: 0 $sp-1 $sp-2; }
.fs__clock, .fs__badge { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.fs.is-demo, .fs.is-demo .fs__screen, .fs.is-demo .fsb__ic--out, .fs.is-demo .fsb__ic--in,
.fs.is-demo .fs__hint, .fs.is-demo .fs__fill, .fs__stripes { animation: none !important; }
.fs, .fs__screen, .fsb { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fs {
position: relative;
box-sizing: border-box;
width: min(300px, 72vw);
margin: 0;
transform: scale(1);
transform-origin: center center;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fs__screen {
position: relative;
height: clamp(88px, 26vw, 124px);
border-radius: 16px;
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.34), rgba(23, 20, 26, 0.92));
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: border-radius 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fs.is-full {
transform: scale(1.3);
}
.fs.is-full .fs__screen {
border-radius: 0;
}
.fs__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.13) 0 12px, transparent 12px 32px);
animation-name: fs-pan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fs-pan {
from {
transform: translateX(0);
}
to {
transform: translateX(-64px);
}
}
.fs__world {
position: absolute;
inset: 0;
}
.fs__sun {
position: absolute;
right: 20%;
top: 16%;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, 0.22);
}
.fs__hill {
position: absolute;
bottom: -46%;
height: 80%;
border-radius: 50%;
}
.fs__hill--a {
left: -12%;
width: 70%;
background: rgba(255, 255, 255, 0.16);
}
.fs__hill--b {
right: -16%;
width: 60%;
background: rgba(255, 255, 255, 0.1);
}
.fs__clock,
.fs__badge {
position: absolute;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.fs__clock {
left: 8px;
}
.fs__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
font-weight: 600;
}
.fs__hint {
position: absolute;
left: 50%;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.92);
color: #fff;
font-size: 10px;
line-height: 14px;
font-weight: 700;
white-space: nowrap;
opacity: 0;
transform: translateX(-50%);
}
.fs.is-full .fs__hint {
opacity: 1;
}
.fs__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px 12px;
}
.fs__time {
color: #fff7e6;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(23, 20, 26, 0.7);
}
.fs__time b {
font-weight: 700;
}
.fsb {
position: relative;
box-sizing: border-box;
width: 28px;
height: 28px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.16);
color: #fff;
cursor: pointer;
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fsb:hover {
background: rgba(255, 255, 255, 0.3);
}
.fsb:active {
transform: scale(0.92);
}
.fsb:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.fsb__ic {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.fsb__ic--in {
opacity: 0;
}
.fs.is-full .fsb__ic--out {
opacity: 0;
}
.fs.is-full .fsb__ic--in {
opacity: 1;
}
.fs__fillwrap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, 0.26);
}
.fs__fill {
display: block;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0.26);
}
.fs.is-demo {
animation-name: fs-zoom;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes fs-zoom {
0%, 16% {
transform: scale(1);
}
38%, 64% {
transform: scale(1.3);
}
86%, 100% {
transform: scale(1);
}
}
.fs.is-demo .fs__screen {
animation-name: fs-round;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes fs-round {
0%, 16% {
border-radius: 16px;
}
38%, 64% {
border-radius: 0;
}
86%, 100% {
border-radius: 16px;
}
}
.fs.is-demo .fsb__ic--out,
.fs.is-demo .fsb__ic--in,
.fs.is-demo .fs__hint {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fs.is-demo .fsb__ic--out {
animation-name: fs-ic-out;
}
.fs.is-demo .fsb__ic--in {
animation-name: fs-ic-in;
}
.fs.is-demo .fs__hint {
animation-name: fs-hint;
}
@keyframes fs-ic-out {
0%, 26% {
opacity: 1;
}
27%, 74% {
opacity: 0;
}
75%, 100% {
opacity: 1;
}
}
@keyframes fs-ic-in {
0%, 26% {
opacity: 0;
}
27%, 74% {
opacity: 1;
}
75%, 100% {
opacity: 0;
}
}
@keyframes fs-hint {
0%, 26% {
opacity: 0;
}
27%, 74% {
opacity: 1;
}
75%, 100% {
opacity: 0;
}
}
.fs.is-demo .fs__fill {
animation-name: fs-fill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fs-fill {
0% {
transform: scaleX(0.26);
}
86%, 100% {
transform: scaleX(0.62);
}
}
@media (max-width: 340px) {
.fs__bar {
padding: 0 4px 8px;
}
.fs__clock, .fs__badge {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.fs.is-demo, .fs.is-demo .fs__screen, .fs.is-demo .fsb__ic--out, .fs.is-demo .fsb__ic--in,
.fs.is-demo .fs__hint, .fs.is-demo .fs__fill, .fs__stripes {
animation: none !important;
}
.fs, .fs__screen, .fsb {
transition: none;
}
}// 실제 동작: 버튼으로 무대를 채우고 Escape 로 돌아온다. 진짜 Fullscreen API 는 iframe 안에서 못 쓴다 — transform 으로 흉내 낸다.
var root = document.getElementById('fs'),
btn = document.getElementById('fsb'),
clock = document.getElementById('clock'),
now = document.getElementById('t-now'),
fill = document.getElementById('fill');
var DUR = 180, t = 47;
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
setInterval(function () {
t = (t + .5) % DUR;
clock.textContent = fmt(t);
now.textContent = fmt(t);
fill.style.transform = 'scaleX(' + (t / DUR).toFixed(4) + ')';
}, 500);
function full(on) {
root.classList.remove('is-demo');
root.classList.toggle('is-full', on);
btn.setAttribute('aria-expanded', String(on));
btn.setAttribute('aria-label', on ? '전체화면 나가기' : '전체화면');
}
btn.addEventListener('click', function () { full(!root.classList.contains('is-full')); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && root.classList.contains('is-full')) {
full(false);
btn.focus({ preventScroll: true });
}
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FullscreenToggle.module.scss";
export type FullscreenToggleProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function ExpandIcon() {
return (
<svg viewBox="0 0 256 256" width="17" height="17" aria-hidden="true">
<g fill="currentColor">
<path d="M208 48v160H48V48Z" opacity=".2" />
<path d="M216 48v40a8 8 0 0 1-16 0V56h-32a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8M88 200H56v-32a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16m120-40a8 8 0 0 0-8 8v32h-32a8 8 0 0 0 0 16h40a8 8 0 0 0 8-8v-40a8 8 0 0 0-8-8M88 40H48a8 8 0 0 0-8 8v40a8 8 0 0 0 16 0V56h32a8 8 0 0 0 0-16" />
</g>
</svg>
);
}
function CollapseIcon() {
return (
<svg viewBox="0 0 256 256" width="17" height="17" aria-hidden="true">
<g fill="currentColor">
<path d="M208 64v128a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V64a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2" />
<path d="M152 96V48a8 8 0 0 1 16 0v40h40a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8m-56 56H48a8 8 0 0 0 0 16h40v40a8 8 0 0 0 16 0v-48a8 8 0 0 0-8-8m112 0h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-40h40a8 8 0 0 0 0-16M96 40a8 8 0 0 0-8 8v40H48a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" />
</g>
</svg>
);
}
export default function FullscreenToggle({
total = 180,
start = 47,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FullscreenToggleProps) {
const [full, setFull] = useState(false);
const [t, setT] = useState(start);
const btnRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — 바닐라의 setInterval(500ms, +0.5초) 을 그대로
useEffect(() => {
const id = setInterval(() => setT((p) => (p + 0.5) % total), 500);
return () => clearInterval(id);
}, [total]);
// Escape 로 나간다. 진짜 Fullscreen API 는 iframe 안에서 못 쓴다 — transform 으로 흉내 낸다
useEffect(() => {
if (!full) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setFull(false);
btnRef.current?.focus({ preventScroll: true });
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [full]);
return (
<figure className={`${styles.player} ${full ? styles.isFull : ""}`} style={vars}>
<div className={styles.screen}>
<div className={styles.stripes} aria-hidden="true" />
<div className={styles.world} aria-hidden="true">
<span className={styles.sun} />
<span className={`${styles.hill} ${styles.hillA}`} />
<span className={`${styles.hill} ${styles.hillB}`} />
</div>
<span className={styles.clock}>{fmt(t)}</span>
<span className={styles.badge}>{fmt(total)}</span>
<span className={styles.hint} aria-hidden="true">Esc 로 나가기</span>
<div className={styles.bar}>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<button
ref={btnRef}
className={styles.fsb}
type="button"
aria-expanded={full}
aria-label={full ? "전체화면 나가기" : "전체화면"}
onClick={() => setFull((v) => !v)}
>
<span className={`${styles.ic} ${styles.icOut}`}>
<ExpandIcon />
</span>
<span className={`${styles.ic} ${styles.icIn}`}>
<CollapseIcon />
</span>
</button>
</div>
<span className={styles.fillwrap} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleX(${(t / total).toFixed(4)})` }} />
</span>
</div>
</figure>
);
}08 풀스크린 토글 — 측정 누적면적 56.881% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 동영상 플레이어 만들기 9종 — 영상 파일 없이도 진짜로 돈다
이 데모가 속한 글
동영상 플레이어 만들기 9종 — 영상 파일 없이도 진짜로 돈다동영상 플레이어 만들기는 브라우저가 기본으로 주는 밋밋한 재생 막대를 버리고 재생 버튼·시간 바·음량·자막을 직접 그려 붙이는 일입니다. 여기 아홉 개는 영상 파일을 하나도 쓰지 않고, 화면 속 풍경까지 CSS 로 이 글의 데모 전부 보기


