08Fullscreen toggle
The expand button grows the player to fill the stage and flips its corner icon to compress; pressing again or hitting Escape brings it back.
Best forCode-along tutorials that viewers want stretched across the whole screen
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">Press Esc to exit</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="Fullscreen">
<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 ? 'Exit fullscreen' : 'Fullscreen');
}
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 Fullscreen toggle — Measured area 56.881% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Custom Video Player CSS Controls, No Video File
The post this demo lives in
9 Custom Video Player CSS Controls, No Video FileA custom video player css setup replaces the browser's default control bar with your own markup and styles. All nine con See all demos in this post


