06ライン描画の枠
SVG の枠が stroke-dashoffset を減らしながら一筆で描かれ、線が閉じると中の文字が続いて点きます。pathLength を 100 に固定しているので長さの計算は不要です。
使いどころ展示・雑誌の表紙風の入り
stroke-dasharraystroke-dashoffsetpathLength

<main class="stage">
<div class="ld is-demo" id="ld">
<figure class="ld__frame">
<svg class="ld__svg" viewBox="0 0 320 200" aria-hidden="true" focusable="false">
<rect class="ld__rect" x="6" y="6" width="308" height="188" rx="8" pathLength="100" />
</svg>
<div class="ld__inner">
<p class="ld__kicker">秋の特別展</p>
<h1 class="ld__title">線の箱舟</h1>
<p class="ld__sub">10月4日から28日まで</p>
</div>
</figure>
<button class="ld__skip" type="button" id="ldSkip">スキップ</button>
<p class="ld__sr" aria-live="polite" id="ldLive"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 라인 드로잉 테두리 — SVG rect 에 pathLength="100" 을 고정해 두고
// stroke-dasharray: 100 · stroke-dashoffset 100 → 0 만으로 한 붓 그리기를 만든다(길이 계산이 필요 없다).
// 선이 닫히는 56% 부터 안쪽 글자가 steps(1, end) 컷으로 켜지고 translateY 로 뒤따라 올라온다.
// 기본(애니메이션 없는) 상태 = dashoffset 0 · 글자 다 보임 = 완성된 표지다.
// 대비(실측, run/016/_probe/대비_a2.py): 제목 $subject-ink/$stage-yellow 12.63:1 ·
// 상단 라벨·부제 rgba($subject-ink,.72) 합성 #584924 위 6.08:1 · 선(비문자 3:1) $subject-ink 12.63:1.
$duration: $dur-loop; // 미리보기 루프 주기 — 설정 초(2s)와 같다
$easing: $ease-spring; // 한 붓의 이징
$color: $subject-ink; // 테두리 선색 — 노랑 무대 위
@include stage($stage-yellow);
.stage { padding: 0; } // 표지가 무대 한가운데 크게 앉는다
.ld { position: relative; width: 100%; height: 100vh; display: grid; place-items: center; }
// 표지 상자 — SVG 테두리와 글자가 같은 상자를 공유한다
.ld__frame {
position: relative; margin: 0;
width: min(84%, 384px); aspect-ratio: 8 / 5;
}
.ld__svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.ld__rect {
fill: none; stroke: $color; stroke-width: 3;
stroke-dasharray: 100; // pathLength 100 기준 — 그려질 최대 길이
stroke-dashoffset: 0; // 기본값 0 = 선이 다 그어진 완성 상태
}
.ld__inner {
position: absolute; inset: $sp-4;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: $sp-2; text-align: center;
}
.ld__kicker {
margin: 0; color: rgba($subject-ink, .72);
font-weight: 700; font-size: 12px; line-height: 1.4; letter-spacing: .14em;
}
.ld__title {
margin: 0; color: $subject-ink;
font-weight: 800; font-size: clamp(24px, 7vw, 36px); line-height: 1.2; letter-spacing: .02em;
}
.ld__sub {
margin: 0; color: rgba($subject-ink, .72);
font-weight: 500; font-size: clamp(13px, 3.4vw, 15px); line-height: 1.5;
}
// 건너뛰기 — 노랑 무대 위에서도 알약 자체가 대비를 갖는다
.ld__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;
}
.ld__skip:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
// 화면 낭독기용 상태 한 줄 — 눈에는 보이지 않는다
.ld__sr {
position: absolute; width: 1px; height: 1px;
clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}
// ── 격자 미리보기 루프 (2초)
// 선은 0~56% 에 한 붓으로 그려지고 닫힌 채 유지된다. 되돌아감은 루프 경계(100%)의 컷 한 번뿐이다.
.ld.is-demo .ld__rect {
animation-name: ldDraw;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
// 글자 — opacity 컷(steps)과 올라옴(ease) 두 애니메이션. delay 가 있으므로 fill-mode: backwards 로
// 지연 구간을 0% 값(안 보임)으로 채운다 — 없으면 첫 프레임에 완성 상태가 번쩍인다.
.ld.is-demo .ld__kicker,
.ld.is-demo .ld__title,
.ld.is-demo .ld__sub {
animation-name: ldTextOn, ldTextRise;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
// 선이 닫히는 56%(1.12s) 부터 하나씩 — 상단 라벨, 제목, 부제
.ld.is-demo .ld__kicker { animation-delay: 1.12s, 1.12s; }
.ld.is-demo .ld__title { animation-delay: 1.2s, 1.2s; }
.ld.is-demo .ld__sub { animation-delay: 1.28s, 1.28s; }
@keyframes ldDraw {
0% { stroke-dashoffset: 100; }
56% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: 0; }
}
@keyframes ldTextOn {
0% { opacity: 0; }
2% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes ldTextRise {
0% { transform: translateY(10px); }
10% { transform: translateY(0); }
100% { transform: translateY(0); }
}
// ── 좁은 화면: 표지가 세로 여유(320×200) 안에 들어오게 크기를 줄인다
@media (max-width: 340px) {
.ld__frame { width: min(88%, 288px); }
.ld__title { font-size: 22px; }
.ld__sub { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-demo .ld__rect,
.ld.is-demo .ld__kicker,
.ld.is-demo .ld__title,
.ld.is-demo .ld__sub {
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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: 0;
}
.ld {
position: relative;
width: 100%;
height: 100vh;
display: grid;
place-items: center;
}
.ld__frame {
position: relative;
margin: 0;
width: min(84%, 384px);
aspect-ratio: 8/5;
}
.ld__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.ld__rect {
fill: none;
stroke: #17141a;
stroke-width: 3;
stroke-dasharray: 100;
stroke-dashoffset: 0;
}
.ld__inner {
position: absolute;
inset: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
text-align: center;
}
.ld__kicker {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 12px;
line-height: 1.4;
letter-spacing: 0.14em;
}
.ld__title {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(24px, 7vw, 36px);
line-height: 1.2;
letter-spacing: 0.02em;
}
.ld__sub {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-weight: 500;
font-size: clamp(13px, 3.4vw, 15px);
line-height: 1.5;
}
.ld__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;
}
.ld__skip:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ld__sr {
position: absolute;
width: 1px;
height: 1px;
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
}
.ld.is-demo .ld__rect {
animation-name: ldDraw;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.ld.is-demo .ld__kicker,
.ld.is-demo .ld__title,
.ld.is-demo .ld__sub {
animation-name: ldTextOn, ldTextRise;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.ld.is-demo .ld__kicker {
animation-delay: 1.12s, 1.12s;
}
.ld.is-demo .ld__title {
animation-delay: 1.2s, 1.2s;
}
.ld.is-demo .ld__sub {
animation-delay: 1.28s, 1.28s;
}
@keyframes ldDraw {
0% {
stroke-dashoffset: 100;
}
56% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes ldTextOn {
0% {
opacity: 0;
}
2% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes ldTextRise {
0% {
transform: translateY(10px);
}
10% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.ld__frame {
width: min(88%, 288px);
}
.ld__title {
font-size: 22px;
}
.ld__sub {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-demo .ld__rect,
.ld.is-demo .ld__kicker,
.ld.is-demo .ld__title,
.ld.is-demo .ld__sub {
animation: none !important;
}
}(function () {
var root = document.getElementById('ld');
var skipBtn = document.getElementById('ldSkip');
var live = document.getElementById('ldLive');
var KEY = 'intro06-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('イントロをスキップしました'); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') complete('イントロをスキップしました');
});
// 한 번 본 세션은 처음부터 완성된 표지로 시작한다
try {
if (sessionStorage.getItem(KEY)) {
root.classList.remove('is-demo');
root.classList.add('is-done');
skipBtn.hidden = true;
live.textContent = '見済みのイントロのためすぐ開きました';
}
} catch (err) { /* sessionStorage 접근이 막히면 그냥 그리기를 보여준다 */ }
})();// LineDrawFrame — vanilla 판(run/016/demo/06-intro-line-draw-frame/index.html) 의 React 포트.
// SVG rect 를 pathLength 100 으로 고정하고 stroke-dashoffset 100 → 0 으로 한 붓 그리기를 만든다.
// 스킵 버튼·Esc·sessionStorage 재방문 처리를 훅으로 옮겼고, @keyframes 본문은 vanilla 와 같은 글자다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LineDrawFrame.module.scss";
export type LineDrawFrameProps = {
duration?: number; // ms — 미리보기 루프 주기
easing?: string; // 한 붓의 이징
color?: string; // 테두리 선색
storageKey?: string; // sessionStorage 키 — 편마다 다르게
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string };
const KICKER = "가을 특별전";
const TITLE = "선의 방주";
const SUB = "10월 4일부터 28일까지";
const SKIP = "건너뛰기";
const MSG_SKIP = "인트로를 건너뛰었습니다";
const MSG_AGAIN = "이미 본 인트로라 바로 열었습니다";
export default function LineDrawFrame(props: LineDrawFrameProps) {
const duration = props.duration ?? 2000;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#17141a";
const key = props.storageKey ?? "intro06-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)} style={vars}>
<figure className={styles.frame}>
<svg className={styles.svg} viewBox="0 0 320 200" aria-hidden="true" focusable="false">
<rect className={styles.rect} x="6" y="6" width="308" height="188" rx="8" pathLength="100" />
</svg>
<div className={styles.inner}>
<p className={styles.kicker}>{KICKER}</p>
<h1 className={styles.title}>{TITLE}</h1>
<p className={styles.sub}>{SUB}</p>
</div>
</figure>
{!done && (
<button className={styles.skip} type="button" onClick={skipNow}>
{SKIP}
</button>
)}
<p className={styles.sr} aria-live="polite">{msg}</p>
</div>
</main>
);
}06 ライン描画の枠 — 計測 累積面積 6.66% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · オープニング アニメーション css 9選 — イントロ アニメーション 入門
このデモが属する記事
オープニング アニメーション css 9選 — イントロ アニメーション 入門オープニング アニメーション css とは、ページが開いた最初の1〜2秒だけ画面を覆い、やがて退いて本来の画面を引き渡す演出のことです。スキップできてこそイントロ — 9つすべてにボタンと Esc と再訪の記録が備わり、その場で終わらせられ この記事のデモをすべて見る


