04Circle mask wipe
A clip-path circle starting from a single center dot grows past the edges and erases the cover, uncovering the content that was already underneath.
Best forOpening transition of a portfolio
clip-path: circle$ease-outanimation-fill-mode

<main class="stage">
<div class="iw is-demo" id="iw">
<div class="iw__cover" aria-hidden="true"><span class="iw__seed"></span></div>
<div class="iw__site">
<div class="iw__box">
<p class="iw__kicker">Portfolio 2026</p>
<h1 class="iw__title">Screens drawn with light</h1>
<p class="iw__sub">Scroll on for the projects</p>
<a class="iw__cta" href="#">View projects</a>
</div>
</div>
<button class="iw__skip" type="button" id="iwSkip">Skip</button>
<p class="iw__sr" aria-live="polite" id="iwLive"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 원형 마스크 와이프 — 잉크 덮개 위에 놓인 첫 화면이 clip-path: circle() 하나로 중앙의 점에서부터 펼쳐진다.
// 움직이는 건 두 가지뿐: .iw__site 의 clip-path(원이 커진다)와 .iw__box 의 transform(살짝 dolly-out).
// 덮개 자체는 정적이고, 기본(애니메이션 없는) 상태 = 완료 상태라 .is-demo 만 떼면 그 자리가 끝난 화면이다.
// 대비(실측, 스크래치 run/016/_probe/대비_a2.py): 제목 $subject-ink/$stage-paper 18.24:1 ·
// 부제 rgba($subject-ink,.72) 합성 #58565a 위 7.26:1 · CTA $subject-cream/$subject-blue-deep 5.98:1 ·
// 씨앗 점 $subject-cream/$subject-ink 17.11:1.
$duration: $dur-loop; // 미리보기 루프 주기 — 설정 초(2s)와 같다
$easing: $ease-out; // 원의 확장 이징
$color: $subject-blue-deep; // CTA 면색 — 크림 글자를 얹는 파란 면은 이 토큰이다
@include stage($stage-paper);
.stage { padding: 0; } // 인트로는 무대를 가득 채운다 — 덮개가 화면 끝까지 가야 와이프로 읽힌다
.iw { position: relative; width: 100%; height: 100vh; }
// 덮개 — 아래 깔린 잉크 전면. 원이 커질수록 위의 .iw__site 에 가려진다
.iw__cover { position: absolute; inset: 0; z-index: 1; background: $subject-ink; }
// 원이 시작하는 씨앗 점 — circle(0%) 의 중심 표식. 첫 장이 빈 화면이 되지 않게 한다
.iw__seed {
position: absolute; left: 50%; top: 50%;
width: 12px; height: 12px; border-radius: 50%;
transform: translate(-50%, -50%);
background: $subject-cream;
}
// 첫 화면 — clip-path 원이 이 요소를 마스크한다. 배경이 불투명이라 원 안에서 덮개를 완전히 덮는다
.iw__site {
position: absolute; inset: 0; z-index: 2;
display: grid; place-items: center;
box-sizing: border-box; padding: $sp-4;
background: $stage-paper;
}
// 세로 묶음 — dolly-out 은 화면보다 작은 이 상자에 건다(전면 요소를 scale up 하면 scrollWidth 가 넘친다)
.iw__box {
display: flex; flex-direction: column; align-items: center;
width: min(84%, 400px); gap: $sp-2; text-align: center;
}
.iw__kicker {
margin: 0; color: rgba($subject-ink, .72);
font-weight: 700; font-size: 12px; line-height: 1.4; letter-spacing: .08em;
}
.iw__title {
margin: 0; color: $subject-ink;
font-weight: 800; font-size: clamp(24px, 7vw, 40px); line-height: 1.2; letter-spacing: -.01em;
}
.iw__sub {
margin: 0; color: rgba($subject-ink, .72);
font-weight: 500; font-size: clamp(13px, 3.4vw, 16px); line-height: 1.5;
}
.iw__cta {
margin-top: $sp-2; padding: $sp-2 $sp-5; border-radius: $r-pill;
background: $color; color: $subject-cream; text-decoration: none;
font-weight: 700; font-size: 14px; line-height: 1.4;
}
.iw__cta:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 건너뛰기 — 덮개(잉크) 위에서도 첫 화면(종이) 위에서도 읽히도록 자기 배경을 가진 알약
.iw__skip {
position: absolute; top: $sp-2; right: $sp-2; z-index: 3;
padding: $sp-1 $sp-3; border: 1px solid rgba($subject-ink, .28); border-radius: $r-pill;
background: $stage-paper; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 600; font-size: 12px; line-height: 1.4;
}
.iw__skip:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
// 화면 낭독기용 상태 한 줄 — 눈에는 보이지 않는다
.iw__sr {
position: absolute; width: 1px; height: 1px;
clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}
// ── 격자 미리보기 루프 (2초). 정적 상태(기본)는 곧 완료 상태다
// 원은 62% 까지 커지고 그대로 유지된다 — 되돌아감은 루프 경계(100%)의 컷 한 번뿐이라 샘플이 유령을 찍지 않는다.
// 75% 면 이 무대의 모서리까지 닿는다(480×300 기준 참조반지름 400px, 모서리 거리 283px).
.iw.is-demo .iw__site {
animation-name: iwOpen;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.iw.is-demo .iw__box {
animation-name: iwSettle;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
@keyframes iwOpen {
0% { clip-path: circle(0% at 50% 50%); }
62% { clip-path: circle(75% at 50% 50%); }
100% { clip-path: circle(75% at 50% 50%); }
}
@keyframes iwSettle {
0% { transform: scale(1.045); }
78% { transform: scale(1); }
100% { transform: scale(1); }
}
// ── 좁은 화면: 세로 여유(320×200)를 지키기 위해 묶음 간격과 글자를 줄인다
@media (max-width: 340px) {
.iw__site { padding: $sp-3; }
.iw__box { gap: $sp-1; }
.iw__title { font-size: 22px; }
.iw__sub { font-size: 12px; }
.iw__cta { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.iw.is-demo .iw__site,
.iw.is-demo .iw__box {
animation: none !important;
}
}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);
}
.stage {
padding: 0;
}
.iw {
position: relative;
width: 100%;
height: 100vh;
}
.iw__cover {
position: absolute;
inset: 0;
z-index: 1;
background: #17141a;
}
.iw__seed {
position: absolute;
left: 50%;
top: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
transform: translate(-50%, -50%);
background: #fff7e6;
}
.iw__site {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
box-sizing: border-box;
padding: 16px;
background: #ffffff;
}
.iw__box {
display: flex;
flex-direction: column;
align-items: center;
width: min(84%, 400px);
gap: 8px;
text-align: center;
}
.iw__kicker {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 12px;
line-height: 1.4;
letter-spacing: 0.08em;
}
.iw__title {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(24px, 7vw, 40px);
line-height: 1.2;
letter-spacing: -0.01em;
}
.iw__sub {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-weight: 500;
font-size: clamp(13px, 3.4vw, 16px);
line-height: 1.5;
}
.iw__cta {
margin-top: 8px;
padding: 8px 20px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
text-decoration: none;
font-weight: 700;
font-size: 14px;
line-height: 1.4;
}
.iw__cta:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.iw__skip {
position: absolute;
top: 8px;
right: 8px;
z-index: 3;
padding: 4px 12px;
border: 1px solid rgba(23, 20, 26, 0.28);
border-radius: 999px;
background: #ffffff;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 600;
font-size: 12px;
line-height: 1.4;
}
.iw__skip:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.iw__sr {
position: absolute;
width: 1px;
height: 1px;
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
}
.iw.is-demo .iw__site {
animation-name: iwOpen;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.iw.is-demo .iw__box {
animation-name: iwSettle;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: both;
}
@keyframes iwOpen {
0% {
clip-path: circle(0% at 50% 50%);
}
62% {
clip-path: circle(75% at 50% 50%);
}
100% {
clip-path: circle(75% at 50% 50%);
}
}
@keyframes iwSettle {
0% {
transform: scale(1.045);
}
78% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.iw__site {
padding: 12px;
}
.iw__box {
gap: 4px;
}
.iw__title {
font-size: 22px;
}
.iw__sub {
font-size: 12px;
}
.iw__cta {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.iw.is-demo .iw__site,
.iw.is-demo .iw__box {
animation: none !important;
}
}(function () {
var root = document.getElementById('iw');
var skipBtn = document.getElementById('iwSkip');
var live = document.getElementById('iwLive');
var KEY = 'intro04-seen';
// 스킵은 인트로를 그 자리의 완료 상태로 끝낸다 — 완료 = 애니메이션 없는 기본 CSS 상태라 클래스 하나로 된다
function complete(msg) {
root.classList.remove('is-demo');
root.classList.add('is-done');
skipBtn.hidden = true;
live.textContent = msg;
try { sessionStorage.setItem(KEY, '1'); } catch (err) { /* 접근이 막힌 브라우저는 저장 없이 진행 */ }
}
skipBtn.addEventListener('click', function () { complete('Skipped the intro'); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') complete('Skipped the intro');
});
// 한 번 본 세션은 처음부터 완료 상태로 시작한다
try {
if (sessionStorage.getItem(KEY)) {
root.classList.remove('is-demo');
root.classList.add('is-done');
skipBtn.hidden = true;
live.textContent = 'Intro already seen, opened right away';
}
} catch (err) { /* sessionStorage 접근이 막히면 그냥 인트로를 보여준다 */ }
})();// ShapeWipe — vanilla 판(run/016/demo/04-intro-shape-wipe/index.html) 의 React 포트.
// 잉크 덮개 위의 첫 화면이 clip-path: circle() 하나로 중앙의 점에서부터 펼쳐지는 원형 마스크 와이프.
// 스킵 버튼·Esc·sessionStorage 재방문 처리를 훅으로 옮겼고, @keyframes 본문은 vanilla 와 같은 글자다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShapeWipe.module.scss";
export type ShapeWipeProps = {
duration?: number; // ms — 미리보기 루프 주기
easing?: string; // 원의 확장 이징
color?: string; // CTA 면색
storageKey?: string; // sessionStorage 키 — 편마다 다르게
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string };
const KICKER = "포트폴리오 2026";
const TITLE = "빛으로 그리는 화면";
const SUB = "스크롤하면 프로젝트가 이어집니다";
const CTA = "프로젝트 보기";
const SKIP = "건너뛰기";
const MSG_SKIP = "인트로를 건너뛰었습니다";
const MSG_AGAIN = "이미 본 인트로라 바로 열었습니다";
export default function ShapeWipe(props: ShapeWipeProps) {
const duration = props.duration ?? 2000;
const easing = props.easing ?? "ease-out";
const color = props.color ?? "#0a4fe8";
const key = props.storageKey ?? "intro04-seen";
const [msg, setMsg] = useState<string>("");
const [done, setDone] = useState<boolean>(() => {
try {
return !!sessionStorage.getItem(key);
} catch {
return false;
}
});
useEffect(() => {
try {
if (sessionStorage.getItem(key)) setMsg(MSG_AGAIN);
} catch {
/* sessionStorage 접근이 막히면 그냥 인트로를 보여준다 */
}
}, [key]);
function skipNow() {
setDone(true);
setMsg(MSG_SKIP);
try {
sessionStorage.setItem(key, "1");
} catch {
/* 접근이 막힌 브라우저는 저장 없이 진행 */
}
}
useEffect(() => {
function onKey(e: KeyEvt) {
if (e.key === "Escape") skipNow();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [key]);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div className={styles.root + (done ? "" : " " + styles.isDemo)} id="iw" style={vars}>
<div className={styles.cover} aria-hidden="true"><span className={styles.seed} /></div>
<div className={styles.site}>
<div className={styles.box}>
<p className={styles.kicker}>{KICKER}</p>
<h1 className={styles.title}>{TITLE}</h1>
<p className={styles.sub}>{SUB}</p>
<a className={styles.cta} href="#">{CTA}</a>
</div>
</div>
{!done && (
<button className={styles.skip} type="button" onClick={skipNow}>
{SKIP}
</button>
)}
<p className={styles.sr} aria-live="polite">{msg}</p>
</div>
</main>
);
}04 Circle mask wipe — Measured area 97.503% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · Intro Animation CSS: 9 Website Intro Animations
The post this demo lives in
Intro Animation CSS: 9 Website Intro AnimationsIntro animation css is the short opening a page plays over its first screen, and all nine here end the same way: an intr See all demos in this post


