css ブラウザ対応 9選 — 実務で使うフォールバック集
9つのcss ブラウザ対応トリックのうち、1つはアドレスバーの開閉のたびに画面全体の高さを再計算し、1つは入力欄にフォーカスしても画面が拡大されないようロックし、1つはリストを端まで引っ張っても背景が一緒に揺れないよう止めます。
01 compat-scrollbar-styling
02 compat-backdrop-filter-prefix
03 compat-100vh-mobile-dvh
04 compat-input-focus-zoom-lock
05 compat-gap-flex-old
06 compat-supports-query
07 compat-overscroll-bounce
08 compat-font-smoothing-fix
09 compat-date-input-styling
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 カスタムスクロールバー — 太さ・色・角丸を自分で決めるスクロールバー
- 02 背景ぼかしフォールバック — ぼかしが効かない環境で半透明背景に置き換わるカード
- 03 動的ビューポート高さ — アドレスバーの大きさに合わせて測り直す画面全体の高さ
- 04 入力欄の拡大ロック — フォーカスしても画面が拡大されない入力フィールド
- 05 フレックス間隔フォールバック — gapが効かない環境でマージンに置き換わる配置
- 06 機能対応分岐 — 対応可否を先に確認して別ルールを切り替える配置
- 07 端の引っ張りロック — リスト端まで背景が一緒に跳ねないよう止めるスクロール
- 08 文字レンダリング補正 — 環境ごとに異なる文字の太さを揃える補正
- 09 日付入力のカスタム — 標準のカレンダーアイコンとフィールド背景を描き直す日付選択
どれもユーザーエージェントの名前を調べていません。実際に見ているのは、あるCSSプロパティに対応しているか、ある関数がプロトタイプに存在するか、フォーカスされている要素のフォントサイズが何pxかという、環境そのものの状態だけです。9つとも .is-demo クラスが付いていて prefers-reduced-motion が有効でない間だけ自動でループし、クリックやキーボード操作が一度入ると .is-demo が外れて手動モードに切り替わります。以下では、その9つを実際に動かしながら、どこでフォールバックの分岐が起きているかを1つずつ見ていきます。
デモ
9つのパターンの解剖 — どのCSSが環境差を埋めるか
9つのパターンには、css 互換性を保つための仕組みが大きく3通りあります。ある環境を狙って分岐を書くのではなく、「今この瞬間に何が使えるか」を先に尋ね、答えに応じて枝分かれする——この順序が9つに共通しています。下の表は、23フレームの撮影中に実際に動いたフレーム数・累積面積・平均強度を並べたものです。
| パターン | 仕組み | 動いたフレーム(24中) | 累積面積 | 平均強度 |
|---|---|---|---|---|
| 01 スクロールバー | 疑似要素→標準プロパティ切替 | 21 | 7.615% | 111.3 |
| 02 背景ぼかし | 半透明色→backdrop-filter切替 | 8 | 7.263% | 46.8 |
| 03 動的ビューポート高さ | JS再計算による高さ復元 | 14 | 5.978% | 167.8 |
| 04 入力拡大ロック | フォントサイズしきい値比較 | 8 | 5.458% | 115.4 |
| 05 フレックス間隔 | margin→gap切替 | 20 | 2.822% | 145.9 |
| 06 機能対応分岐 | CSS.supports()による判定 | 10 | 21.491% | 79.1 |
| 07 引っ張りロック | wheelイベントによる制御 | 19 | 9.72% | 106.9 |
| 08 文字レンダリング補正 | 太さ・字間補正の切替 | 23 | 3.592% | 196.5 |
| 09 日付入力 | showPicker対応検査 | 18 | 3.485% | 131.6 |
スタイルシートが先に決める組(01・02・05・07・08)
01・02・05・08 は、フォールバックの見た目をスタイルシートに常に宣言しておき、その下に置いた @supports ブロックが対応環境でだけルールを上書きします。宣言の順序そのものが分岐の仕組みで、JavaScriptは分岐に関与しません。07 も overscroll-behavior を同じ形でスタイルシートに書いていますが、これはあくまで補助で、実際にリストの引っ張りを止めているのは後述する wheel イベントリスナーです。累積面積では08が23/23フレームで最も動き、05は2.822%と9つの中で最小でした。
スクリプトが直接尋ねる組(06・09)
06と09は、スタイルシートの条件分岐をまったく使わず、実行中の環境にスクリプトから直接尋ねます。06は CSS.supports() を5回呼び出して機能ごとの対応状況を配列にまとめ、対応・非対応のラベルをその場で描き分けます。09は 'showPicker' in HTMLInputElement.prototype という一行の真偽値だけで、ボタンの挙動を丸ごと切り替えます。06は累積面積21.491%で9つの中で最大でした。
数値のしきい値で比較する組(03・04)
03と04には @supports も機能検査もありません。03はアドレスバーの開閉から VH_RECALC_DEBOUNCE_MS だけ遅れて高さを再計算する「固定版」と「動的版」を並べて比較し、04は現在のフォントサイズを LOCK_FONT_PX としきい値比較して拡大率を計算します。どちらも対応可否ではなく、数値の遅延やしきい値そのものがフォールバックの境目になっています。
パターン別コード
9つとも実際のソースから連続した範囲をそのまま切り出しています。コメントだけ日本語に訳し、変数名・関数名・画面表示文言は実際にデモで表示される通りです。
01カスタムスクロールバー — 太さ・色・角丸を自分で決めるスクロールバー
コンテンツがあふれたときに現れるスクロールバーの太さ・色・角丸を、背景に合わせて自分で指定します。23フレーム中21フレームで動き、累積面積は7.615%、平均強度は111.3でした。標準の scrollbar-width/scrollbar-color に対応していない環境向けの疑似要素スタイルが常に宣言されていて、対応環境だけ @supports が上のルールに置き換えます。
.itemList {
margin: 0;
padding: $sp-2 $sp-3;
list-style: none;
width: 220px;
height: 116px;
overflow-y: scroll;
border-radius: $r-sm;
background: rgba($subject-cream, .06);
// フォールバック: 旧環境向けの疑似要素スクロールバー — 常に宣言されており、標準プロパティに対応する環境では下の@supportsが上書きする
&::-webkit-scrollbar { width: 10px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: $subject-blue; border-radius: $r-pill; }
}
@supports (scrollbar-width: thin) {
.itemList:not(.force-fallback) {
scrollbar-width: thin;
scrollbar-color: $subject-blue-light transparent;
}
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="head">
<span class="modeLabel" id="modeLabel">標準サポート版</span>
<button class="simToggle" id="simToggle" type="button" aria-pressed="false">フォールバックを再現</button>
</div>
<div class="body">
<ol class="itemList" id="list">
<li>設計ドキュメント</li>
<li>カラートークン</li>
<li>余白の規則</li>
<li>ボタンの状態</li>
<li>入力バリデーション</li>
<li>アクセシビリティ点検</li>
<li>パフォーマンス計測</li>
<li>デプロイノート</li>
</ol>
<div class="track" id="track"><div class="trackDot" id="trackDot"></div></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 target(실제 scrollTop 비율) → shown(lerp 추적) 에 있다 — 여기는 두 스크롤바
// 표기법(표준 속성 · 옛 의사요소)과 트랙 점의 모양만 맡는다.
// 표준: scrollbar-width·scrollbar-color. 옛 폴백: ::-webkit-scrollbar 3형제. 두 선언은 같은 목록에 둬도
// 충돌하지 않는다 — 표준을 아는 환경은 표준을, 모르는 환경은 옛 의사요소만 읽는다.
@include stage($stage-ink);
.wrap {
width: 260px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
}
.modeLabel {
color: $subject-cream;
font-size: 11px;
font-weight: 600;
letter-spacing: .02em;
}
.simToggle {
appearance: none;
border: none;
border-radius: $r-pill;
background: rgba($subject-cream, .14);
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: background $dur-instant $ease-out, transform $dur-instant $ease-out;
&:hover { background: rgba($subject-cream, .22); }
&:active { transform: scale(.96); }
&[aria-pressed="true"] { background: $subject-orange-deep; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.body {
display: flex;
align-items: stretch;
gap: $sp-2;
}
.itemList {
margin: 0;
padding: $sp-2 $sp-3;
list-style: none;
width: 220px;
height: 116px;
overflow-y: scroll;
border-radius: $r-sm;
background: rgba($subject-cream, .06);
// 폴백: 옛 브라우저용 의사요소 스크롤바 — 항상 선언돼 있고, 표준 속성을 아는 환경은 아래 @supports 가 덮는다
&::-webkit-scrollbar { width: 10px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: $subject-blue; border-radius: $r-pill; }
}
@supports (scrollbar-width: thin) {
.itemList:not(.force-fallback) {
scrollbar-width: thin;
scrollbar-color: $subject-blue-light transparent;
}
}
// 시뮬레이션 토글 — 실제 UA 감지가 아니라 표준 속성을 강제로 꺼서 옛 폴백 경로가 눈에 보이게 한다
.force-fallback .itemList {
scrollbar-width: auto;
scrollbar-color: unset;
}
.itemList li {
padding: $sp-1 0;
color: $subject-cream;
font-size: 12px;
border-bottom: 1px solid rgba($subject-cream, .1);
&:last-child { border-bottom: none; }
}
.track {
position: relative;
width: 6px;
border-radius: $r-pill;
background: rgba($subject-cream, .1);
}
.trackDot {
position: absolute;
top: 0;
left: 0;
width: 6px;
height: 28px;
border-radius: $r-pill;
background: $subject-blue-light;
transform: translateY(0);
}
.force-fallback .trackDot { background: $subject-orange-deep; }
@media (max-width: 340px) {
.wrap { width: 210px; }
.itemList { width: 174px; height: 96px; font-size: 11px; }
.modeLabel { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.trackDot { transition: none; }
.simToggle { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
width: 260px;
display: flex;
flex-direction: column;
gap: 8px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.modeLabel {
color: #fff7e6;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
.simToggle {
appearance: none;
border: none;
border-radius: 999px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
padding: 4px 12px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: background 140ms ease-out, transform 140ms ease-out;
}
.simToggle:hover {
background: rgba(255, 247, 230, 0.22);
}
.simToggle:active {
transform: scale(0.96);
}
.simToggle[aria-pressed=true] {
background: #c93000;
}
.simToggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.body {
display: flex;
align-items: stretch;
gap: 8px;
}
.itemList {
margin: 0;
padding: 8px 12px;
list-style: none;
width: 220px;
height: 116px;
overflow-y: scroll;
border-radius: 8px;
background: rgba(255, 247, 230, 0.06);
}
.itemList::-webkit-scrollbar {
width: 10px;
}
.itemList::-webkit-scrollbar-track {
background: transparent;
}
.itemList::-webkit-scrollbar-thumb {
background: #2f6df6;
border-radius: 999px;
}
@supports (scrollbar-width: thin) {
.itemList:not(.force-fallback) {
scrollbar-width: thin;
scrollbar-color: #5b8cf8 transparent;
}
}
.force-fallback .itemList {
scrollbar-width: auto;
scrollbar-color: unset;
}
.itemList li {
padding: 4px 0;
color: #fff7e6;
font-size: 12px;
border-bottom: 1px solid rgba(255, 247, 230, 0.1);
}
.itemList li:last-child {
border-bottom: none;
}
.track {
position: relative;
width: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
}
.trackDot {
position: absolute;
top: 0;
left: 0;
width: 6px;
height: 28px;
border-radius: 999px;
background: #5b8cf8;
transform: translateY(0);
}
.force-fallback .trackDot {
background: #c93000;
}
@media (max-width: 340px) {
.wrap {
width: 210px;
}
.itemList {
width: 174px;
height: 96px;
font-size: 11px;
}
.modeLabel {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.trackDot {
transition: none;
}
.simToggle {
transition: none;
}
}(function () {
var LERP_RATE = 0.22; // 트랙 점이 목표 위치를 따라붙는 비율 — 작을수록 관성이 크게 느껴진다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var list = document.getElementById('list');
var track = document.getElementById('track');
var dot = document.getElementById('trackDot');
var simToggle = document.getElementById('simToggle');
var modeLabel = document.getElementById('modeLabel');
var isDemo = true;
var target = 0; // 목표 스크롤 비율 0~1 — 실제 scrollTop 에서 나온다
var shown = 0; // 화면에 그려지는 비율 — target 을 lerp 로 뒤따른다
function ratio() {
var max = list.scrollHeight - list.clientHeight;
return max > 0 ? list.scrollTop / max : 0;
}
function onScroll() { target = ratio(); }
list.addEventListener('scroll', onScroll);
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
list.addEventListener('pointerdown', human);
list.addEventListener('keydown', human);
function setSim(on) {
wrap.classList.toggle('force-fallback', on);
simToggle.setAttribute('aria-pressed', String(on));
modeLabel.textContent = on ? 'フォールバックを再現' : '標準サポート版';
}
var simOn = false;
simToggle.addEventListener('click', function () {
human();
simOn = !simOn;
setSim(simOn);
});
var dir = 1;
var last = 0;
var STEP_MS = 520;
function autoAdvance(ts) {
if (ts - last < STEP_MS) return;
last = ts;
var max = list.scrollHeight - list.clientHeight;
var next = list.scrollTop + dir * (max * 0.42);
if (next >= max - 1) { next = max; dir = -1; }
else if (next <= 1) { next = 0; dir = 1; }
list.scrollTop = next; // 진짜 스크롤 — 위 scroll 리스너가 target 을 갱신한다
}
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
var trackH = track.clientHeight - dot.offsetHeight;
if (reduce) { shown = target; } else { shown += (target - shown) * LERP_RATE; }
dot.style.transform = 'translateY(' + Math.max(0, Math.min(trackH, shown * trackH)) + 'px)';
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// ScrollbarStyling — vanilla 판(run/375/demo/01-compat-scrollbar-styling/index.html) 의 React 포트.
// 표준 scrollbar-width/scrollbar-color 와 옛 ::-webkit-scrollbar 의사요소를 함께 선언하고,
// 트랙 점은 실제 scroll 이벤트에서 나온 비율(target)을 lerp 로 뒤따른다.
import { useEffect, useRef } from "react";
import styles from "./ScrollbarStyling.module.scss";
type Box<T> = { current: T };
const LERP_RATE = 0.22; // 트랙 점이 목표 위치를 따라붙는 비율 — 작을수록 관성이 크게 느껴진다
const STEP_MS = 520;
const ITEMS = ["설계 문서", "색상 토큰", "여백 규칙", "버튼 상태", "입력 검증", "접근성 점검", "성능 측정", "배포 노트"];
export default function ScrollbarStyling() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLOListElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const dotRef = useRef(null) as unknown as Box<HTMLDivElement>;
const simToggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const modeLabelRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const list = listRef.current;
const track = trackRef.current;
const dot = dotRef.current;
const simToggle = simToggleRef.current;
const modeLabel = modeLabelRef.current;
if (!wrap || !list || !track || !dot || !simToggle || !modeLabel) return;
let isDemo = true;
let target = 0;
let shown = 0;
function ratio() {
const max = list!.scrollHeight - list!.clientHeight;
return max > 0 ? list!.scrollTop / max : 0;
}
function onScroll() { target = ratio(); }
list.addEventListener("scroll", onScroll);
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function onPointerDown() { human(); }
function onKeyDown() { human(); }
list.addEventListener("pointerdown", onPointerDown);
list.addEventListener("keydown", onKeyDown);
let simOn = false;
function setSim(on: boolean) {
simOn = on;
wrap!.classList.toggle(styles.forceFallback, on);
simToggle!.setAttribute("aria-pressed", String(on));
modeLabel!.textContent = on ? "폴백 흉내내기" : "표준 지원판";
}
function onSimClick() { human(); simOn = !simOn; setSim(simOn); }
simToggle.addEventListener("click", onSimClick);
let dir = 1;
let last = 0;
function autoAdvance(ts: number) {
if (ts - last < STEP_MS) return;
last = ts;
const max = list!.scrollHeight - list!.clientHeight;
let next = list!.scrollTop + dir * (max * 0.42);
if (next >= max - 1) { next = max; dir = -1; }
else if (next <= 1) { next = 0; dir = 1; }
list!.scrollTop = next;
}
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
const trackH = track!.clientHeight - dot!.offsetHeight;
if (reduce) { shown = target; } else { shown += (target - shown) * LERP_RATE; }
dot!.style.transform = "translateY(" + Math.max(0, Math.min(trackH, shown * trackH)) + "px)";
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
list!.removeEventListener("scroll", onScroll);
list!.removeEventListener("pointerdown", onPointerDown);
list!.removeEventListener("keydown", onKeyDown);
simToggle!.removeEventListener("click", onSimClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.head}>
<span className={styles.modeLabel} ref={modeLabelRef}>표준 지원판</span>
<button className={styles.simToggle} ref={simToggleRef} type="button" aria-pressed="false">폴백 흉내내기</button>
</div>
<div className={styles.body}>
<ol className={styles.itemList} ref={listRef}>
{ITEMS.map(function (s) { return <li key={s}>{s}</li>; })}
</ol>
<div className={styles.track} ref={trackRef}>
<div className={styles.trackDot} ref={dotRef}></div>
</div>
</div>
</div>
</main>
);
}01 · カスタムスクロールバー — 太さ・色・角丸を自分で決めるスクロールバー — 計測 累積面積 7.615% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
02背景ぼかしフォールバック — ぼかしが効かない環境で半透明背景に置き換わるカード
ぼかしフィルターが適用されない環境を検知すると、カード背景が半透明の単色で代わりに満たされ、後ろの内容が透けて見えません。23フレーム中8フレームで動き、累積面積は7.263%、平均強度は46.8でした。半透明の単色背景が常に先に宣言されていて、backdrop-filter に対応する環境だけ @supports がぼかしとより濃い色に上書きします。
.card {
position: relative;
z-index: 2;
width: 160px;
padding: $sp-3 $sp-4;
border-radius: $r-card;
opacity: 0;
transform: scale(.86);
transition: transform $dur-base $ease-elastic, opacity $dur-quick $ease-out;
// フォールバック: ぼかし効果がない環境で半透明の単色を敷く — 常に宣言し、対応環境では@supportsが上書きする
background: rgba($subject-ink, var(--fallback-alpha, .6));
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.wrap:not(.force-fallback) .card {
background: rgba($subject-ink, .28);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="backdrop"></div>
<button class="openBtn" id="openBtn" type="button" aria-pressed="false">カードを開く</button>
<div class="card" id="card">
<p class="cardText">通知3件</p>
</div>
<div class="foot">
<span class="modeLabel" id="modeLabel">ぼかし対応版</span>
<button class="simToggle" id="simToggle" type="button" aria-pressed="false">フォールバックを再現</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 카드 열림 애니메이션의 스프링 오버슛(scale 0.86→1.05→1) 에 있다 — index.html 은
// FALLBACK_BG_ALPHA 로 폴백 배경의 진하기만 넘긴다. 여기는 두 배경 표기법(흐림 · 반투명 단색)과
// 오버슛 곡선만 맡는다.
@include stage($stage-yellow);
.wrap {
position: relative;
width: 240px;
height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-3;
}
.backdrop {
position: absolute;
inset: 0;
border-radius: $r-card;
background:
radial-gradient(circle at 20% 30%, rgba($subject-pink, .9), transparent 55%),
radial-gradient(circle at 80% 70%, rgba($subject-sky, .9), transparent 55%),
radial-gradient(circle at 50% 50%, rgba($subject-lilac, .85), transparent 60%);
}
.openBtn {
position: relative;
z-index: 2;
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
padding: $sp-1 $sp-4;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: translateY(-1px); }
&:active { transform: scale(.95); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.card {
position: relative;
z-index: 2;
width: 160px;
padding: $sp-3 $sp-4;
border-radius: $r-card;
opacity: 0;
transform: scale(.86);
transition: transform $dur-base $ease-elastic, opacity $dur-quick $ease-out;
// 폴백: 흐림이 없을 때 반투명 단색으로 채운다 — 항상 선언, @supports 가 지원판에서 덮어쓴다
background: rgba($subject-ink, var(--fallback-alpha, .6));
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.wrap:not(.force-fallback) .card {
background: rgba($subject-ink, .28);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}
.card.is-open {
opacity: 1;
transform: scale(1);
}
.cardText {
margin: 0;
color: $subject-cream;
font-size: 13px;
font-weight: 600;
text-align: center;
}
.foot {
position: relative;
z-index: 2;
display: flex;
align-items: center;
gap: $sp-2;
}
.modeLabel {
color: $subject-ink;
font-size: 10px;
font-weight: 700;
background: rgba($stage-paper, .82);
padding: $sp-1 $sp-2;
border-radius: $r-xs;
}
.simToggle {
appearance: none;
border: none;
border-radius: $r-pill;
background: rgba($subject-ink, .82);
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
&[aria-pressed="true"] { background: $subject-orange-deep; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.wrap { width: 200px; height: 130px; }
.card { width: 140px; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
.openBtn, .simToggle { transition: none; }
}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);
}
.wrap {
position: relative;
width: 240px;
height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.backdrop {
position: absolute;
inset: 0;
border-radius: 16px;
background: radial-gradient(circle at 20% 30%, rgba(255, 158, 187, 0.9), transparent 55%), radial-gradient(circle at 80% 70%, rgba(110, 200, 255, 0.9), transparent 55%), radial-gradient(circle at 50% 50%, rgba(185, 160, 255, 0.85), transparent 60%);
}
.openBtn {
position: relative;
z-index: 2;
appearance: none;
border: none;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
padding: 4px 16px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.openBtn:hover {
transform: translateY(-1px);
}
.openBtn:active {
transform: scale(0.95);
}
.openBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.card {
position: relative;
z-index: 2;
width: 160px;
padding: 12px 16px;
border-radius: 16px;
opacity: 0;
transform: scale(0.86);
transition: transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 220ms ease-out;
background: rgba(23, 20, 26, var(--fallback-alpha, 0.6));
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.wrap:not(.force-fallback) .card {
background: rgba(23, 20, 26, 0.28);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}
.card.is-open {
opacity: 1;
transform: scale(1);
}
.cardText {
margin: 0;
color: #fff7e6;
font-size: 13px;
font-weight: 600;
text-align: center;
}
.foot {
position: relative;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
}
.modeLabel {
color: #17141a;
font-size: 10px;
font-weight: 700;
background: rgba(255, 255, 255, 0.82);
padding: 4px 8px;
border-radius: 4px;
}
.simToggle {
appearance: none;
border: none;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff7e6;
padding: 4px 12px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.simToggle:active {
transform: scale(0.96);
}
.simToggle[aria-pressed=true] {
background: #c93000;
}
.simToggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wrap {
width: 200px;
height: 130px;
}
.card {
width: 140px;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.openBtn, .simToggle {
transition: none;
}
}(function () {
var FALLBACK_BG_ALPHA = 0.6; // 흐림이 없는 환경에서 카드 배경을 채우는 반투명 진하기 — 변형 목표: 0.6 → 0.85
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var card = document.getElementById('card');
var openBtn = document.getElementById('openBtn');
var simToggle = document.getElementById('simToggle');
var modeLabel = document.getElementById('modeLabel');
var isDemo = true;
card.style.setProperty('--fallback-alpha', FALLBACK_BG_ALPHA);
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
var open = false;
function setOpen(on) {
open = on;
card.classList.toggle('is-open', on);
openBtn.setAttribute('aria-pressed', String(on));
}
openBtn.addEventListener('click', function () { human(); setOpen(!open); });
var simOn = false;
function setSim(on) {
simOn = on;
wrap.classList.toggle('force-fallback', on);
simToggle.setAttribute('aria-pressed', String(on));
modeLabel.textContent = on ? 'ぼかしフォールバック版' : 'ぼかし対応版';
}
simToggle.addEventListener('click', function () { human(); setSim(!simOn); });
var last = 0;
var STEP_MS = 620;
var phase = 0;
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
phase = (phase + 1) % 4;
setOpen(phase === 1 || phase === 2);
if (phase === 0) setSim(!simOn);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// BackdropFilterPrefix — vanilla 판(run/375/demo/02-compat-backdrop-filter-prefix/index.html) 의 React 포트.
// 폴백 배경(반투명 단색)을 항상 선언하고 @supports(backdrop-filter) 가 지원판에서 흐림으로 덮는다.
import { useEffect, useRef } from "react";
import styles from "./BackdropFilterPrefix.module.scss";
type Box<T> = { current: T };
const FALLBACK_BG_ALPHA = 0.6; // 흐림이 없는 환경에서 카드 배경을 채우는 반투명 진하기 — 변형 목표: 0.6 → 0.85
const STEP_MS = 620;
export default function BackdropFilterPrefix() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const cardRef = useRef(null) as unknown as Box<HTMLDivElement>;
const openBtnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const simToggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const modeLabelRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const card = cardRef.current;
const openBtn = openBtnRef.current;
const simToggle = simToggleRef.current;
const modeLabel = modeLabelRef.current;
if (!wrap || !card || !openBtn || !simToggle || !modeLabel) return;
let isDemo = true;
card.style.setProperty("--fallback-alpha", String(FALLBACK_BG_ALPHA));
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
let open = false;
function setOpen(on: boolean) {
open = on;
card!.classList.toggle(styles.isOpen, on);
openBtn!.setAttribute("aria-pressed", String(on));
}
function onOpenClick() { human(); setOpen(!open); }
openBtn.addEventListener("click", onOpenClick);
let simOn = false;
function setSim(on: boolean) {
simOn = on;
wrap!.classList.toggle(styles.forceFallback, on);
simToggle!.setAttribute("aria-pressed", String(on));
modeLabel!.textContent = on ? "흐림 폴백판" : "흐림 지원판";
}
function onSimClick() { human(); setSim(!simOn); }
simToggle.addEventListener("click", onSimClick);
let last = 0;
let phase = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
phase = (phase + 1) % 4;
setOpen(phase === 1 || phase === 2);
if (phase === 0) setSim(!simOn);
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
openBtn!.removeEventListener("click", onOpenClick);
simToggle!.removeEventListener("click", onSimClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.backdrop}></div>
<button className={styles.openBtn} ref={openBtnRef} type="button" aria-pressed="false">카드 열기</button>
<div className={styles.card} ref={cardRef}>
<p className={styles.cardText}>알림 3건</p>
</div>
<div className={styles.foot}>
<span className={styles.modeLabel} ref={modeLabelRef}>흐림 지원판</span>
<button className={styles.simToggle} ref={simToggleRef} type="button" aria-pressed="false">폴백 흉내내기</button>
</div>
</div>
</main>
);
}02 · 背景ぼかしフォールバック — ぼかしが効かない環境で半透明背景に置き換わるカード — 計測 累積面積 7.263% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
03動的ビューポート高さ — アドレスバーの大きさに合わせて測り直す画面全体の高さ
アドレスバーが開閉するたびに画面全体の高さの値が再計算され、下部のボタンが画面外へ押し出されません。23フレーム中14フレームで動き、累積面積は5.978%、平均強度は167.8でした。ここには @supports はなく、VH_RECALC_DEBOUNCE_MS だけ遅れて高さのカスタムプロパティを更新する「動的版」と、そもそも再計算しない「固定版」をJavaScriptで並べて比較しています。
function recalc() {
var barH = collapsed ? 0 : BAR_OPEN_PX;
// 動的ビューポート版だけが測り直す — 100vh固定版は実際に再計算しないバグをそのまま見せている
screenDvh.style.setProperty('--bar-h', barH + 'px');
anchorDvh.classList.add('is-settled');
}
function scheduleRecalc() {
anchorDvh.classList.remove('is-settled');
if (timer) clearTimeout(timer);
timer = setTimeout(recalc, VH_RECALC_DEBOUNCE_MS);
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="addrToggle" id="addrToggle" type="button" aria-pressed="false">アドレスバーを格納</button>
<div class="phones">
<div class="phone">
<span class="phoneLabel">100vh固定版</span>
<div class="screen" id="screenFixed">
<div class="addressBar" id="addressBarFixed"></div>
<button class="anchor is-fixed" id="anchorFixed" type="button">続ける</button>
</div>
</div>
<div class="phone">
<span class="phoneLabel">動的ビューポート版</span>
<div class="screen" id="screenDvh">
<div class="addressBar" id="addressBarDvh"></div>
<button class="anchor is-dvh" id="anchorDvh" type="button">続ける</button>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 recalc()/scheduleRecalc() 에 있다 — 주소창 상태가 바뀌면 VH_RECALC_DEBOUNCE_MS
// 뒤에야 --bar-h 커스텀 속성이 갱신된다(실제 동적 뷰포트 재계산의 지연을 흉내). 여기는 두 판(고정·동적)의
// 프레임 모양과, --bar-h 를 transform 으로 반영하는 규칙만 맡는다.
@include stage($stage-ink);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.addrToggle {
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-blue;
color: $subject-cream;
padding: $sp-1 $sp-4;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: translateY(-1px); }
&:active { transform: scale(.95); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.phones {
display: flex;
gap: $sp-4;
}
.phone {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
}
.phoneLabel {
color: $subject-cream;
font-size: 10px;
font-weight: 600;
}
.screen {
position: relative;
width: 96px;
height: 148px;
border-radius: $r-sm;
overflow: hidden;
background: rgba($subject-cream, .08);
box-shadow: inset 0 0 0 2px rgba($subject-cream, .18);
}
.addressBar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 26px;
background: $subject-blue-deep;
transform: translateY(0);
transition: transform $dur-base $ease-out;
z-index: 2;
}
.is-collapsed .addressBar { transform: translateY(100%); }
.anchor {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
padding: $sp-1 0;
border: none;
border-radius: $r-xs;
background: $subject-pink;
color: $subject-ink;
font-size: 9px;
font-weight: 700;
cursor: pointer;
}
// 고정판: 100vh 만 아는 환경 — 주소창이 접히고 펼쳐져도 버튼이 다시 계산되지 않는다(버그 그대로 재현)
.anchor.is-fixed {
z-index: 1;
transform: translateY(0);
}
// 동적 뷰포트판: dvh 를 아는 환경 — --bar-h 만큼 위로 밀어 올려 항상 주소창 위에 머문다
.anchor.is-dvh {
z-index: 2;
transform: translateY(calc(-1 * var(--bar-h, 0px)));
transition: transform $dur-base $ease-out;
opacity: .5;
}
.anchor.is-dvh.is-settled { opacity: 1; }
@media (max-width: 340px) {
.wrap { gap: $sp-1; }
.phones { gap: $sp-2; }
.screen { width: 72px; height: 104px; }
.addressBar { height: 20px; }
.anchor { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.addressBar { transition: none; }
.anchor.is-dvh { transition: none; }
.addrToggle { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.addrToggle {
appearance: none;
border: none;
border-radius: 999px;
background: #2f6df6;
color: #fff7e6;
padding: 4px 16px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.addrToggle:hover {
transform: translateY(-1px);
}
.addrToggle:active {
transform: scale(0.95);
}
.addrToggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.phones {
display: flex;
gap: 16px;
}
.phone {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.phoneLabel {
color: #fff7e6;
font-size: 10px;
font-weight: 600;
}
.screen {
position: relative;
width: 96px;
height: 148px;
border-radius: 8px;
overflow: hidden;
background: rgba(255, 247, 230, 0.08);
box-shadow: inset 0 0 0 2px rgba(255, 247, 230, 0.18);
}
.addressBar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 26px;
background: #0a4fe8;
transform: translateY(0);
transition: transform 300ms ease-out;
z-index: 2;
}
.is-collapsed .addressBar {
transform: translateY(100%);
}
.anchor {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
padding: 4px 0;
border: none;
border-radius: 4px;
background: #ff9ebb;
color: #17141a;
font-size: 9px;
font-weight: 700;
cursor: pointer;
}
.anchor.is-fixed {
z-index: 1;
transform: translateY(0);
}
.anchor.is-dvh {
z-index: 2;
transform: translateY(calc(-1 * var(--bar-h, 0px)));
transition: transform 300ms ease-out;
opacity: 0.5;
}
.anchor.is-dvh.is-settled {
opacity: 1;
}
@media (max-width: 340px) {
.wrap {
gap: 4px;
}
.phones {
gap: 8px;
}
.screen {
width: 72px;
height: 104px;
}
.addressBar {
height: 20px;
}
.anchor {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.addressBar {
transition: none;
}
.anchor.is-dvh {
transition: none;
}
.addrToggle {
transition: none;
}
}(function () {
var VH_RECALC_DEBOUNCE_MS = 150; // 주소창이 움직인 뒤 높이를 다시 계산하기까지의 지연 — 변형 목표: 150 → 50
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var addrToggle = document.getElementById('addrToggle');
var screenDvh = document.getElementById('screenDvh');
var anchorDvh = document.getElementById('anchorDvh');
var isDemo = true;
var BAR_OPEN_PX = 34; // 주소창이 펼쳐졌을 때 화면에서 차지하는 높이
var timer = null;
var collapsed = false;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function recalc() {
var barH = collapsed ? 0 : BAR_OPEN_PX;
// 동적 뷰포트판만 다시 잰다 — 100vh 고정판은 실제로 재계산을 안 하는 버그를 그대로 보여준다
screenDvh.style.setProperty('--bar-h', barH + 'px');
anchorDvh.classList.add('is-settled');
}
function scheduleRecalc() {
anchorDvh.classList.remove('is-settled');
if (timer) clearTimeout(timer);
timer = setTimeout(recalc, VH_RECALC_DEBOUNCE_MS);
}
function setCollapsed(on) {
collapsed = on;
wrap.classList.toggle('is-collapsed', on);
addrToggle.textContent = on ? 'アドレスバーを展開' : 'アドレスバーを格納';
addrToggle.setAttribute('aria-pressed', String(on));
scheduleRecalc();
}
addrToggle.addEventListener('click', function () { human(); setCollapsed(!collapsed); });
addrToggle.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') human();
});
recalc();
var last = 0;
var STEP_MS = 780;
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
setCollapsed(!collapsed);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// DvhMobileViewport — vanilla 판(run/375/demo/03-compat-100vh-mobile-dvh/index.html) 의 React 포트.
// 100vh 고정판은 주소창이 접히고 펼쳐져도 재계산하지 않는 버그를 그대로 보여주고, 동적 뷰포트판은
// VH_RECALC_DEBOUNCE_MS 뒤에 --bar-h 를 다시 놓는다.
import { useEffect, useRef } from "react";
import styles from "./DvhMobileViewport.module.scss";
type Box<T> = { current: T };
const VH_RECALC_DEBOUNCE_MS = 150; // 주소창이 움직인 뒤 높이를 다시 계산하기까지의 지연 — 변형 목표: 150 → 50
const BAR_OPEN_PX = 34; // 주소창이 펼쳐졌을 때 화면에서 차지하는 높이
const STEP_MS = 780;
export default function DvhMobileViewport() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const addrToggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const screenDvhRef = useRef(null) as unknown as Box<HTMLDivElement>;
const anchorDvhRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
const timerRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const addrToggle = addrToggleRef.current;
const screenDvh = screenDvhRef.current;
const anchorDvh = anchorDvhRef.current;
if (!wrap || !addrToggle || !screenDvh || !anchorDvh) return;
let isDemo = true;
let collapsed = false;
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function recalc() {
const barH = collapsed ? 0 : BAR_OPEN_PX;
screenDvh!.style.setProperty("--bar-h", barH + "px");
anchorDvh!.classList.add(styles.isSettled);
}
function scheduleRecalc() {
anchorDvh!.classList.remove(styles.isSettled);
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(recalc, VH_RECALC_DEBOUNCE_MS);
}
function setCollapsed(on: boolean) {
collapsed = on;
wrap!.classList.toggle(styles.isCollapsed, on);
addrToggle!.textContent = on ? "주소창 펼치기" : "주소창 접기";
addrToggle!.setAttribute("aria-pressed", String(on));
scheduleRecalc();
}
function onClick() { human(); setCollapsed(!collapsed); }
function onKeyDown(e: KeyboardEvent) { if (e.key === "Enter" || e.key === " ") human(); }
addrToggle.addEventListener("click", onClick);
addrToggle.addEventListener("keydown", onKeyDown);
recalc();
let last = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
setCollapsed(!collapsed);
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
addrToggle!.removeEventListener("click", onClick);
addrToggle!.removeEventListener("keydown", onKeyDown);
window.clearTimeout(timerRef.current);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button className={styles.addrToggle} ref={addrToggleRef} type="button" aria-pressed="false">주소창 접기</button>
<div className={styles.phones}>
<div className={styles.phone}>
<span className={styles.phoneLabel}>100vh 고정판</span>
<div className={styles.screen}>
<div className={styles.addressBar}></div>
<button className={styles.anchor + " " + styles.isFixed} type="button">계속하기</button>
</div>
</div>
<div className={styles.phone}>
<span className={styles.phoneLabel}>동적 뷰포트판</span>
<div className={styles.screen} ref={screenDvhRef}>
<div className={styles.addressBar}></div>
<button className={styles.anchor + " " + styles.isDvh} ref={anchorDvhRef} type="button">계속하기</button>
</div>
</div>
</div>
</div>
</main>
);
}03 · 動的ビューポート高さ — アドレスバーの大きさに合わせて測り直す画面全体の高さ — 計測 累積面積 5.978% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
04入力欄の拡大ロック — フォーカスしても画面が拡大されない入力フィールド
入力フィールドをタップしてフォーカスしても、基準フォントサイズがしきい値を超えているため画面全体が拡大されずそのまま保たれます。23フレーム中8フレームで動き、累積面積は5.458%、平均強度は115.4でした。現在のフォントサイズを LOCK_FONT_PX で割った比率をズーム倍率として計算し、フォーカスの発生・解除に合わせて transform: scale() を直接切り替えます。
function zoomScaleFor(el) {
var css = getComputedStyle(el);
var current = parseFloat(css.fontSize) || LOCK_FONT_PX;
return Math.max(1, LOCK_FONT_PX / current);
}
function applyFocusZoom(el, on) {
if (on) {
var rect = el.getBoundingClientRect();
var originX = rect.width / 2;
var originY = rect.height / 2;
el.style.transformOrigin = originX + 'px ' + originY + 'px';
el.style.transform = 'scale(' + zoomScaleFor(el) + ')';
} else {
el.style.transform = 'scale(1)';
}
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="row">
<label class="field">
<span class="fieldLabel">小さい文字版</span>
<input class="input is-small" id="inputSmall" type="text" value="検索語">
</label>
<label class="field">
<span class="fieldLabel">ロック版</span>
<input class="input is-lock" id="inputLock" type="text" value="検索語">
</label>
</div>
<div class="foot">
<span class="modeLabel" id="modeLabel">ズームロック有効</span>
<button class="simToggle" id="simToggle" type="button" aria-pressed="false">フォールバックを再現</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 zoomScaleFor() 에 있다 — 실제 computed font-size 에서 배율을 구하고
// getBoundingClientRect() 중심으로 transform-origin 을 놓는다. 여기는 두 입력판의 바탕 크기와
// 전환 곡선만 맡는다.
@include stage($stage-orange);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.row {
display: flex;
gap: $sp-5;
}
.field {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.fieldLabel {
color: $subject-ink;
font-size: 10px;
font-weight: 700;
}
.input {
width: 96px;
padding: $sp-2 $sp-3;
border: none;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
text-align: center;
transition: transform $dur-base $ease-spring;
transform: scale(1);
&.is-small { font-size: 12px; }
&.is-lock { font-size: 16px; }
&:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
}
// 시뮬레이션: 표준 잠금 기법을 강제로 무시하고 잠금판도 확대되게 한다(실제 UA 감지가 아니라
// 값을 억지로 12px 로 되돌리는 CSS 오버라이드) — 폴백 없는 환경을 그대로 보여준다
.force-fallback .input.is-lock { font-size: 12px; }
.foot {
display: flex;
align-items: center;
gap: $sp-2;
}
.modeLabel {
color: $subject-ink;
font-size: 10px;
font-weight: 700;
background: rgba($stage-paper, .6);
padding: $sp-1 $sp-2;
border-radius: $r-xs;
}
.simToggle {
appearance: none;
border: none;
border-radius: $r-pill;
background: rgba($subject-ink, .82);
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
&[aria-pressed="true"] { background: $subject-orange-deep; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.row { gap: $sp-3; }
.input { width: 80px; }
}
@media (prefers-reduced-motion: reduce) {
.input { transition: none; }
.simToggle { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.row {
display: flex;
gap: 20px;
}
.field {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.fieldLabel {
color: #17141a;
font-size: 10px;
font-weight: 700;
}
.input {
width: 96px;
padding: 8px 12px;
border: none;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
text-align: center;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
transform: scale(1);
}
.input.is-small {
font-size: 12px;
}
.input.is-lock {
font-size: 16px;
}
.input:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.force-fallback .input.is-lock {
font-size: 12px;
}
.foot {
display: flex;
align-items: center;
gap: 8px;
}
.modeLabel {
color: #17141a;
font-size: 10px;
font-weight: 700;
background: rgba(255, 255, 255, 0.6);
padding: 4px 8px;
border-radius: 4px;
}
.simToggle {
appearance: none;
border: none;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff7e6;
padding: 4px 12px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.simToggle:active {
transform: scale(0.96);
}
.simToggle[aria-pressed=true] {
background: #c93000;
}
.simToggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.row {
gap: 12px;
}
.input {
width: 80px;
}
}
@media (prefers-reduced-motion: reduce) {
.input {
transition: none;
}
.simToggle {
transition: none;
}
}(function () {
var LOCK_FONT_PX = 16; // 이 값 이상이면 초점 확대가 걸리지 않는다 — 잠금판의 목표 크기
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var inputSmall = document.getElementById('inputSmall');
var inputLock = document.getElementById('inputLock');
var simToggle = document.getElementById('simToggle');
var modeLabel = document.getElementById('modeLabel');
var isDemo = true;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
// 실제 요소 크기에서 확대 배율을 구한다 — 하드코딩 배율이 아니라 현재 폰트 크기 기준
function zoomScaleFor(el) {
var css = getComputedStyle(el);
var current = parseFloat(css.fontSize) || LOCK_FONT_PX;
return Math.max(1, LOCK_FONT_PX / current);
}
function applyFocusZoom(el, on) {
if (on) {
var rect = el.getBoundingClientRect();
var originX = rect.width / 2;
var originY = rect.height / 2;
el.style.transformOrigin = originX + 'px ' + originY + 'px';
el.style.transform = 'scale(' + zoomScaleFor(el) + ')';
} else {
el.style.transform = 'scale(1)';
}
}
inputSmall.addEventListener('focus', function () { human(); applyFocusZoom(inputSmall, true); });
inputSmall.addEventListener('blur', function () { applyFocusZoom(inputSmall, false); });
// 잠금판: 실제 font-size 가 이미 LOCK_FONT_PX 이므로 zoomScaleFor 가 1을 반환 — 확대가 걸리지 않는다
inputLock.addEventListener('focus', function () { human(); applyFocusZoom(inputLock, true); });
inputLock.addEventListener('blur', function () { applyFocusZoom(inputLock, false); });
var simOn = false;
function setSim(on) {
simOn = on;
wrap.classList.toggle('force-fallback', on);
simToggle.setAttribute('aria-pressed', String(on));
modeLabel.textContent = on ? 'ズームロック無効' : 'ズームロック有効';
}
simToggle.addEventListener('click', function () { human(); setSim(!simOn); });
var last = 0;
var STEP_MS = 680;
var phase = 0;
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
phase = (phase + 1) % 4;
if (phase === 1) { applyFocusZoom(inputSmall, true); applyFocusZoom(inputLock, true); }
else if (phase === 3) { applyFocusZoom(inputSmall, false); applyFocusZoom(inputLock, false); }
else if (phase === 0) { setSim(!simOn); }
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// InputFocusZoomLock — vanilla 판(run/375/demo/04-compat-input-focus-zoom-lock/index.html) 의 React 포트.
// 작은 글자판은 초점을 얻으면 확대되고, 잠금 글자판은 font-size 16px 기법 때문에 배율이 1로 고정된다.
import { useEffect, useRef } from "react";
import styles from "./InputFocusZoomLock.module.scss";
type Box<T> = { current: T };
const LOCK_FONT_PX = 16; // 이 값 이상이면 초점 확대가 걸리지 않는다 — 잠금판의 목표 크기
const STEP_MS = 680;
export default function InputFocusZoomLock() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const inputSmallRef = useRef(null) as unknown as Box<HTMLInputElement>;
const inputLockRef = useRef(null) as unknown as Box<HTMLInputElement>;
const simToggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const modeLabelRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const inputSmall = inputSmallRef.current;
const inputLock = inputLockRef.current;
const simToggle = simToggleRef.current;
const modeLabel = modeLabelRef.current;
if (!wrap || !inputSmall || !inputLock || !simToggle || !modeLabel) return;
let isDemo = true;
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
// 실제 요소 크기에서 확대 배율을 구한다 — 하드코딩 배율이 아니라 현재 폰트 크기 기준
function zoomScaleFor(el: HTMLInputElement) {
const css = window.getComputedStyle(el);
const current = parseFloat(css.fontSize) || LOCK_FONT_PX;
return Math.max(1, LOCK_FONT_PX / current);
}
function applyFocusZoom(el: HTMLInputElement, on: boolean) {
if (on) {
const rect = el.getBoundingClientRect();
const originX = rect.width / 2;
const originY = rect.height / 2;
el.style.transformOrigin = originX + "px " + originY + "px";
el.style.transform = "scale(" + zoomScaleFor(el) + ")";
} else {
el.style.transform = "scale(1)";
}
}
function onSmallFocus() { human(); applyFocusZoom(inputSmall!, true); }
function onSmallBlur() { applyFocusZoom(inputSmall!, false); }
inputSmall.addEventListener("focus", onSmallFocus);
inputSmall.addEventListener("blur", onSmallBlur);
// 잠금판: 실제 font-size 가 이미 LOCK_FONT_PX 이므로 zoomScaleFor 가 1을 반환 — 확대가 걸리지 않는다
function onLockFocus() { human(); applyFocusZoom(inputLock!, true); }
function onLockBlur() { applyFocusZoom(inputLock!, false); }
inputLock.addEventListener("focus", onLockFocus);
inputLock.addEventListener("blur", onLockBlur);
let simOn = false;
function setSim(on: boolean) {
simOn = on;
wrap!.classList.toggle(styles.forceFallback, on);
simToggle!.setAttribute("aria-pressed", String(on));
modeLabel!.textContent = on ? "확대 잠금 꺼짐" : "확대 잠금 켜짐";
}
function onSimClick() { human(); setSim(!simOn); }
simToggle.addEventListener("click", onSimClick);
let last = 0;
let phase = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
phase = (phase + 1) % 4;
if (phase === 1) { applyFocusZoom(inputSmall!, true); applyFocusZoom(inputLock!, true); }
else if (phase === 3) { applyFocusZoom(inputSmall!, false); applyFocusZoom(inputLock!, false); }
else if (phase === 0) { setSim(!simOn); }
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
inputSmall!.removeEventListener("focus", onSmallFocus);
inputSmall!.removeEventListener("blur", onSmallBlur);
inputLock!.removeEventListener("focus", onLockFocus);
inputLock!.removeEventListener("blur", onLockBlur);
simToggle!.removeEventListener("click", onSimClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.row}>
<label className={styles.field}>
<span className={styles.fieldLabel}>작은 글자판</span>
<input className={styles.input + " " + styles.isSmall} ref={inputSmallRef} type="text" defaultValue="검색어" />
</label>
<label className={styles.field}>
<span className={styles.fieldLabel}>잠금 글자판</span>
<input className={styles.input + " " + styles.isLock} ref={inputLockRef} type="text" defaultValue="검색어" />
</label>
</div>
<div className={styles.foot}>
<span className={styles.modeLabel} ref={modeLabelRef}>확대 잠금 켜짐</span>
<button className={styles.simToggle} ref={simToggleRef} type="button" aria-pressed="false">폴백 흉내내기</button>
</div>
</div>
</main>
);
}04 · 入力欄の拡大ロック — フォーカスしても画面が拡大されない入力フィールド — 計測 累積面積 5.458% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
05フレックス間隔フォールバック — gapが効かない環境でマージンに置き換わる配置
gapプロパティに対応していない環境を検知すると、各項目に個別のマージンを与えて同じ間隔を再現します。23フレーム中20フレームで動き、累積面積は2.822%で9つの中で最小、平均強度は145.9でした。最後の項目以外に右マージンを与える宣言が常にあり、gap に対応する環境だけ @supports がマージンを0に戻してgapに置き換えます。
.chipRow {
display: flex;
flex-wrap: wrap;
width: 100%;
min-height: 34px;
margin: 0;
padding: 0;
list-style: none;
// フォールバック: gapを知らない環境 — 最後のチップ以外に右マージンを与える。常に宣言し、対応環境では下の@supportsが上書きする
> .chip:not(:last-child) { margin-right: $sp-2; }
}
@supports (gap: 1rem) {
.wrap:not(.force-fallback) .chipRow {
gap: $sp-2;
> .chip:not(:last-child) { margin-right: 0; }
}
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="head">
<input class="addInput" id="addInput" type="text" placeholder="タグを入力してEnter" maxlength="8">
<button class="simToggle" id="simToggle" type="button" aria-pressed="false">フォールバックを再現</button>
</div>
<ul class="chipRow" id="chipRow">
<li class="chip">余白</li>
<li class="chip">トークン</li>
<li class="chip">整列</li>
</ul>
<span class="modeLabel" id="modeLabel">gap対応版</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 칩 등장 애니메이션의 --i 기반 시차(stagger)에 있다 — index.html 이 칩마다 순서를
// CSS 변수로 넘긴다. 여기는 두 간격 표기법(margin 폴백 · gap 지원)과 등장 곡선만 맡는다.
@include stage($stage-paper);
.wrap {
width: 260px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.head {
display: flex;
align-items: center;
gap: $sp-2;
width: 100%;
}
.addInput {
flex: 1 1 auto;
min-width: 0;
border: none;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
padding: $sp-1 $sp-2;
font-size: 11px;
&::placeholder { color: rgba($subject-ink, .45); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.simToggle {
flex: 0 0 auto;
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
padding: $sp-1 $sp-2;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
&[aria-pressed="true"] { background: $subject-orange-deep; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.chipRow {
display: flex;
flex-wrap: wrap;
width: 100%;
min-height: 34px;
margin: 0;
padding: 0;
list-style: none;
// 폴백: gap 을 모르는 환경 — 마지막 칩만 빼고 오른쪽 여백을 준다. 항상 선언, 아래 @supports 가 지원판에서 덮는다
> .chip:not(:last-child) { margin-right: $sp-2; }
}
@supports (gap: 1rem) {
.wrap:not(.force-fallback) .chipRow {
gap: $sp-2;
> .chip:not(:last-child) { margin-right: 0; }
}
}
.chip {
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-blue;
color: $subject-cream;
padding: $sp-2 $sp-4;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transform: scale(1);
transition: transform $dur-enter $ease-elastic;
transition-delay: calc(var(--i, 0) * 40ms);
&:hover { transform: scale(1.08); }
}
.chip.is-entering { transform: scale(0); transition-duration: $dur-base; }
.chip.is-leaving { transform: scale(0); transition-delay: 0ms; }
.modeLabel {
color: $subject-ink;
font-size: 10px;
font-weight: 700;
}
@media (max-width: 340px) {
.wrap { width: 220px; }
.chipRow { min-height: 30px; }
}
@media (prefers-reduced-motion: reduce) {
.chip { transition: none; }
.simToggle { 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);
}
.wrap {
width: 260px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.addInput {
flex: 1 1 auto;
min-width: 0;
border: none;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
padding: 4px 8px;
font-size: 11px;
}
.addInput::placeholder {
color: rgba(23, 20, 26, 0.45);
}
.addInput:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.simToggle {
flex: 0 0 auto;
appearance: none;
border: none;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
padding: 4px 8px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.simToggle:active {
transform: scale(0.96);
}
.simToggle[aria-pressed=true] {
background: #c93000;
}
.simToggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.chipRow {
display: flex;
flex-wrap: wrap;
width: 100%;
min-height: 34px;
margin: 0;
padding: 0;
list-style: none;
}
.chipRow > .chip:not(:last-child) {
margin-right: 8px;
}
@supports (gap: 1rem) {
.wrap:not(.force-fallback) .chipRow {
gap: 8px;
}
.wrap:not(.force-fallback) .chipRow > .chip:not(:last-child) {
margin-right: 0;
}
}
.chip {
appearance: none;
border: none;
border-radius: 999px;
background: #2f6df6;
color: #fff7e6;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transform: scale(1);
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
transition-delay: calc(var(--i, 0) * 40ms);
}
.chip:hover {
transform: scale(1.08);
}
.chip.is-entering {
transform: scale(0);
transition-duration: 300ms;
}
.chip.is-leaving {
transform: scale(0);
transition-delay: 0ms;
}
.modeLabel {
color: #17141a;
font-size: 10px;
font-weight: 700;
}
@media (max-width: 340px) {
.wrap {
width: 220px;
}
.chipRow {
min-height: 30px;
}
}
@media (prefers-reduced-motion: reduce) {
.chip {
transition: none;
}
.simToggle {
transition: none;
}
}(function () {
var STEP_MS = 380; // 자동 추가/삭제 間隔
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var chipRow = document.getElementById('chipRow');
var addInput = document.getElementById('addInput');
var simToggle = document.getElementById('simToggle');
var modeLabel = document.getElementById('modeLabel');
var isDemo = true;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
var seq = 0;
function addChip(text) {
var li = document.createElement('li');
li.className = 'chip is-entering';
li.textContent = text;
li.style.setProperty('--i', String(seq % 6));
seq += 1;
li.addEventListener('click', function () { human(); removeChip(li); });
chipRow.appendChild(li);
requestAnimationFrame(function () {
requestAnimationFrame(function () { li.classList.remove('is-entering'); });
});
}
function removeChip(li) {
if (!li || li.classList.contains('is-leaving')) return;
li.classList.add('is-leaving');
li.addEventListener('transitionend', function () { li.remove(); }, { once: true });
}
addInput.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
human();
var value = addInput.value.trim();
if (value) { addChip(value); addInput.value = ''; }
});
var simOn = false;
function setSim(on) {
simOn = on;
wrap.classList.toggle('force-fallback', on);
simToggle.setAttribute('aria-pressed', String(on));
modeLabel.textContent = on ? 'marginフォールバック版' : 'gap対応版';
}
simToggle.addEventListener('click', function () { human(); setSim(!simOn); });
var WORDS = ['整列', '間隔', '規則', 'トークン', 'フロー'];
var wi = 0;
var last = 0;
var phase = 0;
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
phase = (phase + 1) % 3;
if (phase === 0) {
addChip(WORDS[wi % WORDS.length]);
wi += 1;
} else if (phase === 1 && chipRow.children.length > 2) {
removeChip(chipRow.lastElementChild);
} else {
setSim(!simOn);
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// GapFlexOld — vanilla 판(run/375/demo/05-compat-gap-flex-old/index.html) 의 React 포트.
// 폴백은 항상 margin-right 로 간격을 주고, @supports(gap) 지원판에서 gap 이 그 역할을 넘겨받는다.
import { useEffect, useRef } from "react";
import styles from "./GapFlexOld.module.scss";
type Box<T> = { current: T };
const STEP_MS = 380; // 자동 추가/삭제 간격
export default function GapFlexOld() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const chipRowRef = useRef(null) as unknown as Box<HTMLUListElement>;
const addInputRef = useRef(null) as unknown as Box<HTMLInputElement>;
const simToggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const modeLabelRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const chipRow = chipRowRef.current;
const addInput = addInputRef.current;
const simToggle = simToggleRef.current;
const modeLabel = modeLabelRef.current;
if (!wrap || !chipRow || !addInput || !simToggle || !modeLabel) return;
let isDemo = true;
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
let seq = 0;
function addChip(text: string) {
const li = document.createElement("li");
li.className = styles.chip + " " + styles.isEntering;
li.textContent = text;
li.style.setProperty("--i", String(seq % 6));
seq += 1;
li.addEventListener("click", function () { human(); removeChip(li); });
chipRow!.appendChild(li);
requestAnimationFrame(function () {
requestAnimationFrame(function () { li.classList.remove(styles.isEntering); });
});
}
function removeChip(li: Element | null) {
if (!li || li.classList.contains(styles.isLeaving)) return;
li.classList.add(styles.isLeaving);
li.addEventListener("transitionend", function () { (li as HTMLElement).remove(); }, { once: true });
}
function onKeydown(e: KeyboardEvent) {
if (e.key !== "Enter") return;
human();
const value = addInput!.value.trim();
if (value) { addChip(value); addInput!.value = ""; }
}
addInput.addEventListener("keydown", onKeydown);
let simOn = false;
function setSim(on: boolean) {
simOn = on;
wrap!.classList.toggle(styles.forceFallback, on);
simToggle!.setAttribute("aria-pressed", String(on));
modeLabel!.textContent = on ? "margin 폴백판" : "gap 지원판";
}
function onSimClick() { human(); setSim(!simOn); }
simToggle.addEventListener("click", onSimClick);
const WORDS = ["정렬", "간격", "규칙", "토큰", "흐름"];
let wi = 0;
let last = 0;
let phase = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
phase = (phase + 1) % 3;
if (phase === 0) {
addChip(WORDS[wi % WORDS.length]);
wi += 1;
} else if (phase === 1 && chipRow!.children.length > 2) {
removeChip(chipRow!.lastElementChild);
} else {
setSim(!simOn);
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
addInput!.removeEventListener("keydown", onKeydown);
simToggle!.removeEventListener("click", onSimClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.head}>
<input className={styles.addInput} ref={addInputRef} type="text" placeholder="태그 입력 후 Enter" maxLength={8} />
<button className={styles.simToggle} ref={simToggleRef} type="button" aria-pressed="false">폴백 흉내내기</button>
</div>
<ul className={styles.chipRow} ref={chipRowRef}>
<li className={styles.chip}>여백</li>
<li className={styles.chip}>토큰</li>
<li className={styles.chip}>정렬</li>
</ul>
<span className={styles.modeLabel} ref={modeLabelRef}>gap 지원판</span>
</div>
</main>
);
}05 · フレックス間隔フォールバック — gapが効かない環境でマージンに置き換わる配置 — 計測 累積面積 2.822% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
06機能対応分岐 — 対応可否を先に確認して別ルールを切り替える配置
特定の機能の対応可否を先に検査し、対応していれば新しいルールを、対応していなければ従来のルールをそのまま残す形で画面が分かれます。23フレーム中10フレームしか動いていないのに累積面積は21.491%で9つの中で最大、平均強度は79.1でした。判定は @supports ではなくスクリプト側の CSS.supports() で、5つの機能を配列にまとめて一括で検査します。
// 実際の@supportsと同じ判定根拠 — CSS.supports()で今のレンダリング環境をそのまま検査する
var FEATURES = [
{ label: 'Flex gap', test: function () { return CSS.supports('gap', '1px'); } },
{ label: 'アスペクト比', test: function () { return CSS.supports('aspect-ratio', '1 / 1'); } },
{ label: '背景ぼかし', test: function () { return CSS.supports('backdrop-filter', 'blur(1px)'); } },
{ label: '親セレクター', test: function () { return CSS.supports('selector(:has(a))'); } },
{ label: 'コンテナークエリー', test: function () { return CSS.supports('container-type', 'inline-size'); } }
];

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="head">
<span class="modeLabel" id="modeLabel">実際のサポート判定</span>
<button class="rescanBtn" id="rescanBtn" type="button">再チェック</button>
</div>
<ul class="checkList" id="checkList"></ul>
<div class="bar"><div class="barFill" id="barFill"></div></div>
<button class="simToggle" id="simToggle" type="button" aria-pressed="false">フォールバックを再現</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 scan() 에 있다 — CSS.supports() 로 실제 렌더 환경을 검사해 okCount/전체
// 비율을 구하고 그 비율을 막대 scaleX 에 그대로 반영한다(하드코딩 비율 아님). 여기는 목록 행의
// 등장 시차와 막대·아이콘 표기만 맡는다.
@include stage($stage-ink);
.wrap {
width: 240px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.modeLabel {
color: $subject-cream;
font-size: 10px;
font-weight: 700;
}
.rescanBtn {
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-blue;
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.94); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.checkList {
width: 100%;
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: $sp-1;
}
.checkRow {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: rgba($subject-cream, .06);
opacity: 0;
transform: translateY(6px);
transition: opacity $dur-base $ease-out, transform $dur-base $ease-out;
transition-delay: calc(var(--i, 0) * 55ms);
}
.checkRow.is-revealed {
opacity: 1;
transform: translateY(0);
}
.checkRow.is-hidden {
opacity: 0;
transform: translateY(6px);
transition-delay: 0ms;
}
.statusIcon {
flex: 0 0 auto;
width: 12px;
height: 12px;
border-radius: $r-pill;
background: $subject-blue-light;
}
.checkRow.is-ok .statusIcon { background: $subject-mint; }
.checkRow.is-fallback .statusIcon { background: $error-soft; }
.featureName {
color: $subject-cream;
font-size: 11px;
}
.bar {
width: 100%;
height: 6px;
border-radius: $r-pill;
background: rgba($subject-cream, .12);
overflow: hidden;
}
.barFill {
width: 100%;
height: 100%;
border-radius: $r-pill;
background: $subject-mint;
transform-origin: left center;
transform: scaleX(0);
transition: transform $dur-base $ease-out;
}
.simToggle {
appearance: none;
border: none;
border-radius: $r-pill;
background: rgba($subject-cream, .14);
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
&[aria-pressed="true"] { background: $subject-orange-deep; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.wrap { width: 192px; gap: $sp-1; }
.head { gap: $sp-1; }
.featureName { font-size: 9px; }
.checkList { gap: 0; }
.checkRow { padding: 0 $sp-1; gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.checkRow { transition: none; }
.barFill { transition: none; }
.rescanBtn, .simToggle { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
width: 240px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.modeLabel {
color: #fff7e6;
font-size: 10px;
font-weight: 700;
}
.rescanBtn {
appearance: none;
border: none;
border-radius: 999px;
background: #2f6df6;
color: #fff7e6;
padding: 4px 12px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.rescanBtn:active {
transform: scale(0.94);
}
.rescanBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.checkList {
width: 100%;
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.checkRow {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.06);
opacity: 0;
transform: translateY(6px);
transition: opacity 300ms ease-out, transform 300ms ease-out;
transition-delay: calc(var(--i, 0) * 55ms);
}
.checkRow.is-revealed {
opacity: 1;
transform: translateY(0);
}
.checkRow.is-hidden {
opacity: 0;
transform: translateY(6px);
transition-delay: 0ms;
}
.statusIcon {
flex: 0 0 auto;
width: 12px;
height: 12px;
border-radius: 999px;
background: #5b8cf8;
}
.checkRow.is-ok .statusIcon {
background: #4cd4a6;
}
.checkRow.is-fallback .statusIcon {
background: #ffe0d8;
}
.featureName {
color: #fff7e6;
font-size: 11px;
}
.bar {
width: 100%;
height: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.12);
overflow: hidden;
}
.barFill {
width: 100%;
height: 100%;
border-radius: 999px;
background: #4cd4a6;
transform-origin: left center;
transform: scaleX(0);
transition: transform 300ms ease-out;
}
.simToggle {
appearance: none;
border: none;
border-radius: 999px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
padding: 4px 12px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.simToggle:active {
transform: scale(0.96);
}
.simToggle[aria-pressed=true] {
background: #c93000;
}
.simToggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wrap {
width: 192px;
gap: 4px;
}
.head {
gap: 4px;
}
.featureName {
font-size: 9px;
}
.checkList {
gap: 0;
}
.checkRow {
padding: 0 4px;
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.checkRow {
transition: none;
}
.barFill {
transition: none;
}
.rescanBtn, .simToggle {
transition: none;
}
}(function () {
var STEP_MS = 620; // 자동 재검사/시뮬레이션 間隔
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var checkList = document.getElementById('checkList');
var barFill = document.getElementById('barFill');
var rescanBtn = document.getElementById('rescanBtn');
var simToggle = document.getElementById('simToggle');
var modeLabel = document.getElementById('modeLabel');
var isDemo = true;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
// 실제 @supports 와 같은 판정 근거 — CSS.supports() 로 지금 렌더 환경을 그대로 검사한다
var FEATURES = [
{ label: 'Flex gap', test: function () { return CSS.supports('gap', '1px'); } },
{ label: 'アスペクト比', test: function () { return CSS.supports('aspect-ratio', '1 / 1'); } },
{ label: '背景ぼかし', test: function () { return CSS.supports('backdrop-filter', 'blur(1px)'); } },
{ label: '親セレクター', test: function () { return CSS.supports('selector(:has(a))'); } },
{ label: 'コンテナークエリー', test: function () { return CSS.supports('container-type', 'inline-size'); } }
];
var simOn = false;
var rows = [];
function build() {
checkList.innerHTML = '';
rows = FEATURES.map(function (f, i) {
var li = document.createElement('li');
li.className = 'checkRow is-hidden';
li.style.setProperty('--i', String(i));
var icon = document.createElement('span');
icon.className = 'statusIcon';
var name = document.createElement('span');
name.className = 'featureName';
name.textContent = f.label;
li.appendChild(icon);
li.appendChild(name);
checkList.appendChild(li);
return { el: li, icon: icon, ok: f.test() };
});
}
build();
function scan() {
var okCount = 0;
rows.forEach(function (row, i) {
// 시뮬레이션판: 실제 결과 위에 강제로 미지원을 덧씌워 폴백 문구가 보이게 한다(감지 흉내가 아니라 표시 전용 오버라이드)
var displayOk = simOn ? (row.ok && i % 2 === 0) : row.ok;
row.el.classList.toggle('is-ok', displayOk);
row.el.classList.toggle('is-fallback', !displayOk);
row.el.classList.remove('is-hidden');
row.el.classList.add('is-revealed');
if (displayOk) okCount += 1;
});
var ratio = rows.length ? okCount / rows.length : 0;
barFill.style.transform = 'scaleX(' + ratio + ')';
modeLabel.textContent = simOn ? 'フォールバック再現チェック' : '実際のサポート判定';
}
rescanBtn.addEventListener('click', function () {
human();
rows.forEach(function (row) { row.el.classList.add('is-hidden'); row.el.classList.remove('is-revealed'); });
requestAnimationFrame(function () { requestAnimationFrame(scan); });
});
function setSim(on) {
simOn = on;
simToggle.setAttribute('aria-pressed', String(on));
scan();
}
simToggle.addEventListener('click', function () { human(); setSim(!simOn); });
scan();
var last = 0;
var phase = 0;
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
phase = (phase + 1) % 3;
if (phase === 0) {
rows.forEach(function (row) { row.el.classList.add('is-hidden'); row.el.classList.remove('is-revealed'); });
} else if (phase === 1) {
scan();
} else {
setSim(!simOn);
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// SupportsQuery — vanilla 판(run/375/demo/06-compat-supports-query/index.html) 의 React 포트.
// CSS.supports() 로 실제 렌더 환경을 검사해 항목별 지원/미지원을 표시하고, 통과 비율을 막대 scaleX 로 잰다.
import { useEffect, useRef } from "react";
import styles from "./SupportsQuery.module.scss";
type Box<T> = { current: T };
const STEP_MS = 620; // 자동 재검사/시뮬레이션 간격
export default function SupportsQuery() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const checkListRef = useRef(null) as unknown as Box<HTMLUListElement>;
const barFillRef = useRef(null) as unknown as Box<HTMLDivElement>;
const rescanBtnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const simToggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const modeLabelRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const checkList = checkListRef.current;
const barFill = barFillRef.current;
const rescanBtn = rescanBtnRef.current;
const simToggle = simToggleRef.current;
const modeLabel = modeLabelRef.current;
if (!wrap || !checkList || !barFill || !rescanBtn || !simToggle || !modeLabel) return;
let isDemo = true;
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
// 실제 @supports 와 같은 판정 근거 — CSS.supports() 로 지금 렌더 환경을 그대로 검사한다
const FEATURES = [
{ label: "플렉스 gap", test: function () { return CSS.supports("gap", "1px"); } },
{ label: "가로세로비", test: function () { return CSS.supports("aspect-ratio", "1 / 1"); } },
{ label: "배경 흐림", test: function () { return CSS.supports("backdrop-filter", "blur(1px)"); } },
{ label: "부모 선택자", test: function () { return CSS.supports("selector(:has(a))"); } },
{ label: "컨테이너 쿼리", test: function () { return CSS.supports("container-type", "inline-size"); } }
];
let simOn = false;
let rows: { el: HTMLLIElement; icon: HTMLSpanElement; ok: boolean }[] = [];
function build() {
checkList!.innerHTML = "";
rows = FEATURES.map(function (f, i) {
const li = document.createElement("li");
li.className = styles.checkRow + " " + styles.isHidden;
li.style.setProperty("--i", String(i));
const icon = document.createElement("span");
icon.className = styles.statusIcon;
const name = document.createElement("span");
name.className = styles.featureName;
name.textContent = f.label;
li.appendChild(icon);
li.appendChild(name);
checkList!.appendChild(li);
return { el: li, icon: icon, ok: f.test() };
});
}
build();
function hideAll() {
rows.forEach(function (row) { row.el.classList.add(styles.isHidden); row.el.classList.remove(styles.isRevealed); });
}
function scan() {
let okCount = 0;
rows.forEach(function (row, i) {
// 시뮬레이션판: 실제 결과 위에 강제로 미지원을 덧씌워 폴백 문구가 보이게 한다(감지 흉내가 아니라 표시 전용 오버라이드)
const displayOk = simOn ? (row.ok && i % 2 === 0) : row.ok;
row.el.classList.toggle(styles.isOk, displayOk);
row.el.classList.toggle(styles.isFallback, !displayOk);
row.el.classList.remove(styles.isHidden);
row.el.classList.add(styles.isRevealed);
if (displayOk) okCount += 1;
});
const ratio = rows.length ? okCount / rows.length : 0;
barFill!.style.transform = "scaleX(" + ratio + ")";
modeLabel!.textContent = simOn ? "폴백 흉내 검사" : "실제 지원 검사";
}
function onRescan() {
human();
hideAll();
requestAnimationFrame(function () { requestAnimationFrame(scan); });
}
rescanBtn.addEventListener("click", onRescan);
function setSim(on: boolean) {
simOn = on;
simToggle!.setAttribute("aria-pressed", String(on));
scan();
}
function onSimClick() { human(); setSim(!simOn); }
simToggle.addEventListener("click", onSimClick);
scan();
let last = 0;
let phase = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
phase = (phase + 1) % 3;
if (phase === 0) {
hideAll();
} else if (phase === 1) {
scan();
} else {
setSim(!simOn);
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
rescanBtn!.removeEventListener("click", onRescan);
simToggle!.removeEventListener("click", onSimClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.head}>
<span className={styles.modeLabel} ref={modeLabelRef}>실제 지원 검사</span>
<button className={styles.rescanBtn} ref={rescanBtnRef} type="button">다시 검사</button>
</div>
<ul className={styles.checkList} ref={checkListRef}></ul>
<div className={styles.bar}><div className={styles.barFill} ref={barFillRef}></div></div>
<button className={styles.simToggle} ref={simToggleRef} type="button" aria-pressed="false">폴백 흉내내기</button>
</div>
</main>
);
}06 · 機能対応分岐 — 対応可否を先に確認して別ルールを切り替える配置 — 計測 累積面積 21.491% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
07端の引っ張りロック — リスト端まで背景が一緒に跳ねないよう止めるスクロール
リストを端まで引っ張っても背後の画面は一緒に揺れず、引っ張りはリストの中だけで止まります。23フレーム中19フレームで動き、累積面積は9.72%、平均強度は106.9でした。実際の制御は wheel イベントリスナーが担っていて、境界に達したあとの余ったデルタだけを引っ張り量として吸収し、preventDefault() でスクロールの親要素への伝播を止めます。
function setPull(px) {
pull = Math.max(0, Math.min(OVERSCROLL_LOCK_PX, px));
pullCap.style.transform = 'scaleY(' + (pull / OVERSCROLL_LOCK_PX) + ')';
}
function release() {
setPull(0);
}
// 実際のwheelリスナー — 境界に達した後の余ったデルタだけを引っ張りとして吸収し、スクロールの伝播(チェイニング)を止める
panel.addEventListener('wheel', function (e) {
human();
if (e.deltaY > 0 && atBottom()) {
e.preventDefault();
setPull(pull + e.deltaY * 0.4);
if (releaseTimer) clearTimeout(releaseTimer);
releaseTimer = setTimeout(release, 220);
} else if (e.deltaY < 0 && atTop() && pull > 0) {
e.preventDefault();
setPull(pull + e.deltaY * 0.4);
if (releaseTimer) clearTimeout(releaseTimer);
releaseTimer = setTimeout(release, 220);
}
}, { passive: false });

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="head">
<span class="modeLabel" id="modeLabel">スクロール連鎖ロック版</span>
<button class="simToggle" id="simToggle" type="button" aria-pressed="false">フォールバックを再現</button>
</div>
<div class="panelWrap">
<ul class="panel" id="panel">
<li>通知の規則</li>
<li>バッジの色</li>
<li>余白テーブル</li>
<li>ボタンの状態</li>
<li>入力バリデーション</li>
<li>アクセシビリティ</li>
<li>パフォーマンス計測</li>
<li>デプロイノート</li>
</ul>
<div class="pullCap" id="pullCap"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 setPull() 에 있다 — 실제 wheel 델타를 OVERSCROLL_LOCK_PX 안으로 눌러
// 담고 scaleY 로 당김 캡을 늘인다. 여기는 패널 모양과 당김 캡의 원점(아래 변)만 맡는다.
@include stage($subject-blue-deep);
.wrap {
width: 240px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.modeLabel {
color: $subject-cream;
font-size: 10px;
font-weight: 700;
}
.simToggle {
appearance: none;
border: none;
border-radius: $r-pill;
background: rgba($subject-cream, .16);
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
&[aria-pressed="true"] { background: $subject-orange-deep; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.panelWrap {
position: relative;
width: 190px;
}
.panel {
margin: 0;
padding: $sp-2;
list-style: none;
width: 100%;
height: 130px;
overflow-y: auto;
border-radius: $r-card;
background: rgba($subject-cream, .08);
// 폴백: 표준 속성을 모르는 환경에서도 JS 당김 잠금이 경계를 막는다 — 항상 동작, 아래 @supports 는 보조일 뿐
overscroll-behavior: auto;
}
@supports (overscroll-behavior: contain) {
.wrap:not(.force-fallback) .panel {
overscroll-behavior: contain;
}
}
// 시뮬레이션: 표준 속성이 있어도 강제로 꺼서(auto) 체이닝이 새는 환경을 흉내 — JS 당김 잠금만으로 막히는지 보여준다
.force-fallback .panel {
overscroll-behavior: auto;
}
.panel li {
padding: $sp-1 0;
color: $subject-cream;
font-size: 11px;
border-bottom: 1px solid rgba($subject-cream, .12);
&:last-child { border-bottom: none; }
}
.pullCap {
position: absolute;
left: $sp-2;
right: $sp-2;
bottom: 2px;
height: 6px;
border-radius: $r-pill;
background: $subject-orange-deep;
transform-origin: center bottom;
transform: scaleY(0);
transition: transform $dur-instant $ease-out;
}
@media (max-width: 340px) {
.wrap { width: 208px; }
.panelWrap { width: 168px; }
.panel { height: 112px; }
}
@media (prefers-reduced-motion: reduce) {
.pullCap { transition: none; }
.simToggle { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #0a4fe8;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
width: 240px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.modeLabel {
color: #fff7e6;
font-size: 10px;
font-weight: 700;
}
.simToggle {
appearance: none;
border: none;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
color: #fff7e6;
padding: 4px 12px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.simToggle:active {
transform: scale(0.96);
}
.simToggle[aria-pressed=true] {
background: #c93000;
}
.simToggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.panelWrap {
position: relative;
width: 190px;
}
.panel {
margin: 0;
padding: 8px;
list-style: none;
width: 100%;
height: 130px;
overflow-y: auto;
border-radius: 16px;
background: rgba(255, 247, 230, 0.08);
overscroll-behavior: auto;
}
@supports (overscroll-behavior: contain) {
.wrap:not(.force-fallback) .panel {
overscroll-behavior: contain;
}
}
.force-fallback .panel {
overscroll-behavior: auto;
}
.panel li {
padding: 4px 0;
color: #fff7e6;
font-size: 11px;
border-bottom: 1px solid rgba(255, 247, 230, 0.12);
}
.panel li:last-child {
border-bottom: none;
}
.pullCap {
position: absolute;
left: 8px;
right: 8px;
bottom: 2px;
height: 6px;
border-radius: 999px;
background: #c93000;
transform-origin: center bottom;
transform: scaleY(0);
transition: transform 140ms ease-out;
}
@media (max-width: 340px) {
.wrap {
width: 208px;
}
.panelWrap {
width: 168px;
}
.panel {
height: 112px;
}
}
@media (prefers-reduced-motion: reduce) {
.pullCap {
transition: none;
}
.simToggle {
transition: none;
}
}(function () {
var OVERSCROLL_LOCK_PX = 24; // 경계에서 더 당길 수 있는 최대치 — 변형 목표: 24 → 48
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var panel = document.getElementById('panel');
var pullCap = document.getElementById('pullCap');
var simToggle = document.getElementById('simToggle');
var modeLabel = document.getElementById('modeLabel');
var isDemo = true;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
var pull = 0;
var releaseTimer = null;
function atBottom() {
return panel.scrollTop + panel.clientHeight >= panel.scrollHeight - 1;
}
function atTop() { return panel.scrollTop <= 0; }
function setPull(px) {
pull = Math.max(0, Math.min(OVERSCROLL_LOCK_PX, px));
pullCap.style.transform = 'scaleY(' + (pull / OVERSCROLL_LOCK_PX) + ')';
}
function release() {
setPull(0);
}
// 실제 wheel 리스너 — 경계에 닿은 뒤의 남는 델타만 당김으로 흡수하고 스크롤 전파(체이닝)를 막는다
panel.addEventListener('wheel', function (e) {
human();
if (e.deltaY > 0 && atBottom()) {
e.preventDefault();
setPull(pull + e.deltaY * 0.4);
if (releaseTimer) clearTimeout(releaseTimer);
releaseTimer = setTimeout(release, 220);
} else if (e.deltaY < 0 && atTop() && pull > 0) {
e.preventDefault();
setPull(pull + e.deltaY * 0.4);
if (releaseTimer) clearTimeout(releaseTimer);
releaseTimer = setTimeout(release, 220);
}
}, { passive: false });
panel.addEventListener('scroll', function () { if (pull > 0 && !atBottom()) release(); });
var simOn = false;
function setSim(on) {
simOn = on;
wrap.classList.toggle('force-fallback', on);
simToggle.setAttribute('aria-pressed', String(on));
modeLabel.textContent = on ? 'スクロール連鎖リーク版' : 'スクロール連鎖ロック版';
}
simToggle.addEventListener('click', function () { human(); setSim(!simOn); });
var last = 0;
var STEP_MS = 70;
var dir = 1;
var holdUntil = 0;
function autoAdvance(ts) {
if (ts - last < STEP_MS) return;
last = ts;
if (ts < holdUntil) {
// 경계에서 더 당기는 구간 — 실제 wheel 이 하는 것과 같은 setPull 호출
setPull(pull + (dir > 0 ? 6 : -6));
return;
}
var max = panel.scrollHeight - panel.clientHeight;
var next = panel.scrollTop + dir * 10;
if (next >= max) { panel.scrollTop = max; holdUntil = ts + 360; dir = -1; }
else if (next <= 0) { panel.scrollTop = 0; holdUntil = ts + 360; dir = 1; }
else { panel.scrollTop = next; }
}
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (isDemo && !reduce && pull > 0 && ts > holdUntil + 40) setPull(0);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// OverscrollBounce — vanilla 판(run/375/demo/07-compat-overscroll-bounce/index.html) 의 React 포트.
// 경계에 닿은 뒤의 남는 wheel 델타만 OVERSCROLL_LOCK_PX 안으로 눌러 담아 당김 캡을 늘이고, 스크롤 전파를 막는다.
import { useEffect, useRef } from "react";
import styles from "./OverscrollBounce.module.scss";
type Box<T> = { current: T };
const OVERSCROLL_LOCK_PX = 24; // 경계에서 더 당길 수 있는 최대치 — 변형 목표: 24 → 48
const STEP_MS = 70;
export default function OverscrollBounce() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const panelRef = useRef(null) as unknown as Box<HTMLUListElement>;
const pullCapRef = useRef(null) as unknown as Box<HTMLDivElement>;
const simToggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const modeLabelRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const rafRef = useRef(0) as unknown as Box<number>;
const timerRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const panel = panelRef.current;
const pullCap = pullCapRef.current;
const simToggle = simToggleRef.current;
const modeLabel = modeLabelRef.current;
if (!wrap || !panel || !pullCap || !simToggle || !modeLabel) return;
let isDemo = true;
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
let pull = 0;
let releaseTimer = 0;
function atBottom() {
return panel!.scrollTop + panel!.clientHeight >= panel!.scrollHeight - 1;
}
function atTop() { return panel!.scrollTop <= 0; }
function setPull(px: number) {
pull = Math.max(0, Math.min(OVERSCROLL_LOCK_PX, px));
pullCap!.style.transform = "scaleY(" + (pull / OVERSCROLL_LOCK_PX) + ")";
}
function release() {
setPull(0);
}
// 실제 wheel 리스너 — 경계에 닿은 뒤의 남는 델타만 당김으로 흡수하고 스크롤 전파(체이닝)를 막는다
function onWheel(e: WheelEvent) {
human();
if (e.deltaY > 0 && atBottom()) {
e.preventDefault();
setPull(pull + e.deltaY * 0.4);
window.clearTimeout(releaseTimer);
releaseTimer = window.setTimeout(release, 220);
} else if (e.deltaY < 0 && atTop() && pull > 0) {
e.preventDefault();
setPull(pull + e.deltaY * 0.4);
window.clearTimeout(releaseTimer);
releaseTimer = window.setTimeout(release, 220);
}
}
panel.addEventListener("wheel", onWheel, { passive: false });
function onScroll() { if (pull > 0 && !atBottom()) release(); }
panel.addEventListener("scroll", onScroll);
let simOn = false;
function setSim(on: boolean) {
simOn = on;
wrap!.classList.toggle(styles.forceFallback, on);
simToggle!.setAttribute("aria-pressed", String(on));
modeLabel!.textContent = on ? "체이닝 새는판" : "체이닝 잠금판";
}
function onSimClick() { human(); setSim(!simOn); }
simToggle.addEventListener("click", onSimClick);
let last = 0;
let dir = 1;
let holdUntil = 0;
function autoAdvance(ts: number) {
if (ts - last < STEP_MS) return;
last = ts;
if (ts < holdUntil) {
// 경계에서 더 당기는 구간 — 실제 wheel 이 하는 것과 같은 setPull 호출
setPull(pull + (dir > 0 ? 6 : -6));
return;
}
const max = panel!.scrollHeight - panel!.clientHeight;
const next = panel!.scrollTop + dir * 10;
if (next >= max) { panel!.scrollTop = max; holdUntil = ts + 360; dir = -1; }
else if (next <= 0) { panel!.scrollTop = 0; holdUntil = ts + 360; dir = 1; }
else { panel!.scrollTop = next; }
}
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (isDemo && !reduce && pull > 0 && ts > holdUntil + 40) setPull(0);
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
panel!.removeEventListener("wheel", onWheel);
panel!.removeEventListener("scroll", onScroll);
simToggle!.removeEventListener("click", onSimClick);
window.clearTimeout(releaseTimer);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.head}>
<span className={styles.modeLabel} ref={modeLabelRef}>체이닝 잠금판</span>
<button className={styles.simToggle} ref={simToggleRef} type="button" aria-pressed="false">폴백 흉내내기</button>
</div>
<div className={styles.panelWrap}>
<ul className={styles.panel} ref={panelRef}>
<li>알림 규칙</li>
<li>배지 색</li>
<li>여백 표</li>
<li>버튼 상태</li>
<li>입력 검증</li>
<li>접근성</li>
<li>성능 측정</li>
<li>배포 노트</li>
</ul>
<div className={styles.pullCap} ref={pullCapRef}></div>
</div>
</div>
</main>
);
}07 · 端の引っ張りロック — リスト端まで背景が一緒に跳ねないよう止めるスクロール — 計測 累積面積 9.72% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
08文字レンダリング補正 — 環境ごとに異なる文字の太さを揃える補正
レンダリング方式によって異なって見えることがある文字の線の太さを、補正プロパティで細く滑らかに揃えます。23フレーム中23フレームと9つの中で最も多く動き、平均強度も196.5と最大でした。累積面積は3.592%です。太さと字間だけで見た目を補正するフォールバックが常に宣言されていて、-webkit-font-smoothing/-moz-osx-font-smoothing に対応する環境では @supports がその2つのプロパティを補助として重ねます。
.textLayer.is-raw {
font-weight: 400;
letter-spacing: 0;
text-shadow: 0 0 1.4px rgba($subject-ink, .55), 0 0 .5px rgba($subject-ink, .4);
}
// フォールバック: ベンダースムージングを知らなくても、太さと字間だけでくっきり補正する — 常に宣言、下の@supportsは補助にすぎない
.textLayer.is-fixed {
font-weight: 600;
letter-spacing: .01em;
text-shadow: none;
clip-path: inset(0 calc(100% - var(--split)) 0 0);
transition: clip-path $dur-instant $ease-out;
}
@supports (-webkit-font-smoothing: antialiased) or (font-smoothing: antialiased) {
.wrap:not(.force-fallback) .textLayer.is-fixed {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="compareBox" id="compareBox">
<div class="textLayer is-raw">Aa 本文テキスト</div>
<div class="textLayer is-fixed" id="fixedLayer">Aa 本文テキスト</div>
<div class="handle" id="handle" tabindex="0" role="slider" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50"></div>
</div>
<div class="foot">
<span class="modeLabel" id="modeLabel">補正あり版</span>
<button class="simToggle" id="simToggle" type="button" aria-pressed="false">フォールバックを再現</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 setSplit() 에 있다 — 실제 포인터 clientX 를 박스 폭 비율로 바꿔 --split
// 커스텀 속성에 넣는다. 여기는 그 비율을 clip-path 로 반영하는 규칙과 두 글자층의 대비만 맡는다.
// 실제 서브픽셀 렌더 차이는 이 크기에서 육안 판별이 어려워, 보정 없는 쪽은 번짐(text-shadow)으로
// 과장해 보여준다 — 진짜 보정(.is-fixed)은 폰트 굵기·자간의 수동 교정이 핵심이다.
@include stage($stage-paper);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.compareBox {
--split: 50%;
position: relative;
width: 220px;
height: 70px;
border-radius: $r-card;
overflow: hidden;
background: $subject-cream;
}
.textLayer {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: $subject-ink;
}
.textLayer.is-raw {
font-weight: 400;
letter-spacing: 0;
text-shadow: 0 0 1.4px rgba($subject-ink, .55), 0 0 .5px rgba($subject-ink, .4);
}
// 폴백: 벤더 스무딩을 몰라도 굵기·자간만으로 또렷하게 보정한다 — 항상 선언, 아래 @supports 는 보조
.textLayer.is-fixed {
font-weight: 600;
letter-spacing: .01em;
text-shadow: none;
clip-path: inset(0 calc(100% - var(--split)) 0 0);
transition: clip-path $dur-instant $ease-out;
}
@supports (-webkit-font-smoothing: antialiased) or (font-smoothing: antialiased) {
.wrap:not(.force-fallback) .textLayer.is-fixed {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
.handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--split);
width: 3px;
margin-left: -1.5px;
background: $subject-orange-deep;
cursor: ew-resize;
transition: left $dur-instant $ease-out;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
border-radius: $r-pill;
background: $subject-orange-deep;
transform: translate(-50%, -50%);
}
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.foot {
display: flex;
align-items: center;
gap: $sp-2;
}
.modeLabel {
color: $subject-ink;
font-size: 10px;
font-weight: 700;
}
.simToggle {
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
&[aria-pressed="true"] { background: $subject-orange-deep; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.compareBox { width: 190px; height: 62px; }
.textLayer { font-size: 19px; }
}
@media (prefers-reduced-motion: reduce) {
.textLayer.is-fixed { transition: none; }
.handle { transition: none; }
.simToggle { 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);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.compareBox {
--split: 50%;
position: relative;
width: 220px;
height: 70px;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
}
.textLayer {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: #17141a;
}
.textLayer.is-raw {
font-weight: 400;
letter-spacing: 0;
text-shadow: 0 0 1.4px rgba(23, 20, 26, 0.55), 0 0 0.5px rgba(23, 20, 26, 0.4);
}
.textLayer.is-fixed {
font-weight: 600;
letter-spacing: 0.01em;
text-shadow: none;
clip-path: inset(0 calc(100% - var(--split)) 0 0);
transition: clip-path 140ms ease-out;
}
@supports (-webkit-font-smoothing: antialiased) or (font-smoothing: antialiased) {
.wrap:not(.force-fallback) .textLayer.is-fixed {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
.handle {
position: absolute;
top: 0;
bottom: 0;
left: var(--split);
width: 3px;
margin-left: -1.5px;
background: #c93000;
cursor: ew-resize;
transition: left 140ms ease-out;
}
.handle::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
border-radius: 999px;
background: #c93000;
transform: translate(-50%, -50%);
}
.handle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.foot {
display: flex;
align-items: center;
gap: 8px;
}
.modeLabel {
color: #17141a;
font-size: 10px;
font-weight: 700;
}
.simToggle {
appearance: none;
border: none;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
padding: 4px 12px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.simToggle:active {
transform: scale(0.96);
}
.simToggle[aria-pressed=true] {
background: #c93000;
}
.simToggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.compareBox {
width: 190px;
height: 62px;
}
.textLayer {
font-size: 19px;
}
}
@media (prefers-reduced-motion: reduce) {
.textLayer.is-fixed {
transition: none;
}
.handle {
transition: none;
}
.simToggle {
transition: none;
}
}(function () {
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var compareBox = document.getElementById('compareBox');
var handle = document.getElementById('handle');
var simToggle = document.getElementById('simToggle');
var modeLabel = document.getElementById('modeLabel');
var isDemo = true;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
var split = 50;
function setSplit(v) {
split = Math.max(0, Math.min(100, v));
compareBox.style.setProperty('--split', split + '%');
handle.setAttribute('aria-valuenow', String(Math.round(split)));
}
setSplit(split);
var dragging = false;
handle.addEventListener('pointerdown', function (e) {
human();
dragging = true;
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
var rect = compareBox.getBoundingClientRect();
setSplit(((e.clientX - rect.left) / rect.width) * 100);
});
handle.addEventListener('pointerup', function () { dragging = false; });
handle.addEventListener('keydown', function (e) {
human();
if (e.key === 'ArrowLeft') setSplit(split - 5);
else if (e.key === 'ArrowRight') setSplit(split + 5);
});
var simOn = false;
function setSim(on) {
simOn = on;
wrap.classList.toggle('force-fallback', on);
simToggle.setAttribute('aria-pressed', String(on));
modeLabel.textContent = on ? '補正なし版' : '補正あり版';
}
simToggle.addEventListener('click', function () { human(); setSim(!simOn); });
// 자동 재생: 삼각파(사인이 아니라 선형 왕복)로 손잡이를 오간다 — 난수 없이 결정론적
var last = 0;
var STEP_MS = 40;
var dir = 1;
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
var next = split + dir * 3.4;
if (next >= 92) { next = 92; dir = -1; }
else if (next <= 8) { next = 8; dir = 1; }
setSplit(next);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// FontSmoothingFix — vanilla 판(run/375/demo/08-compat-font-smoothing-fix/index.html) 의 React 포트.
// 벤더 스무딩을 몰라도 폰트 굵기·자간 수동 교정으로 글자를 또렷하게 하고, 슬라이더로 두 판을 오간다.
import { useEffect, useRef } from "react";
import styles from "./FontSmoothingFix.module.scss";
type Box<T> = { current: T };
const STEP_MS = 40;
export default function FontSmoothingFix() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const compareBoxRef = useRef(null) as unknown as Box<HTMLDivElement>;
const handleRef = useRef(null) as unknown as Box<HTMLDivElement>;
const simToggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const modeLabelRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const compareBox = compareBoxRef.current;
const handle = handleRef.current;
const simToggle = simToggleRef.current;
const modeLabel = modeLabelRef.current;
if (!wrap || !compareBox || !handle || !simToggle || !modeLabel) return;
let isDemo = true;
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
let split = 50;
function setSplit(v: number) {
split = Math.max(0, Math.min(100, v));
compareBox!.style.setProperty("--split", split + "%");
handle!.setAttribute("aria-valuenow", String(Math.round(split)));
}
setSplit(split);
let dragging = false;
function onPointerDown(e: PointerEvent) {
human();
dragging = true;
handle!.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const rect = compareBox!.getBoundingClientRect();
setSplit(((e.clientX - rect.left) / rect.width) * 100);
}
function onPointerUp() { dragging = false; }
function onKeyDown(e: KeyboardEvent) {
human();
if (e.key === "ArrowLeft") setSplit(split - 5);
else if (e.key === "ArrowRight") setSplit(split + 5);
}
handle.addEventListener("pointerdown", onPointerDown);
handle.addEventListener("pointermove", onPointerMove);
handle.addEventListener("pointerup", onPointerUp);
handle.addEventListener("keydown", onKeyDown);
let simOn = false;
function setSim(on: boolean) {
simOn = on;
wrap!.classList.toggle(styles.forceFallback, on);
simToggle!.setAttribute("aria-pressed", String(on));
modeLabel!.textContent = on ? "보정 없는판" : "보정 적용판";
}
function onSimClick() { human(); setSim(!simOn); }
simToggle.addEventListener("click", onSimClick);
// 자동 재생: 삼각파(사인이 아니라 선형 왕복)로 손잡이를 오간다 — 난수 없이 결정론적
let last = 0;
let dir = 1;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
let next = split + dir * 3.4;
if (next >= 92) { next = 92; dir = -1; }
else if (next <= 8) { next = 8; dir = 1; }
setSplit(next);
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
handle!.removeEventListener("pointerdown", onPointerDown);
handle!.removeEventListener("pointermove", onPointerMove);
handle!.removeEventListener("pointerup", onPointerUp);
handle!.removeEventListener("keydown", onKeyDown);
simToggle!.removeEventListener("click", onSimClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.compareBox} ref={compareBoxRef}>
<div className={styles.textLayer + " " + styles.isRaw}>Aa 본문 글자</div>
<div className={styles.textLayer + " " + styles.isFixed}>Aa 본문 글자</div>
<div className={styles.handle} ref={handleRef} tabIndex={0} role="slider" aria-valuemin={0} aria-valuemax={100} aria-valuenow={50}></div>
</div>
<div className={styles.foot}>
<span className={styles.modeLabel} ref={modeLabelRef}>보정 적용판</span>
<button className={styles.simToggle} ref={simToggleRef} type="button" aria-pressed="false">폴백 흉내내기</button>
</div>
</div>
</main>
);
}08 · 文字レンダリング補正 — 環境ごとに異なる文字の太さを揃える補正 — 計測 累積面積 3.592% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
09日付入力のカスタム — 標準のカレンダーアイコンとフィールド背景を描き直す日付選択
標準で表示されるカレンダーアイコンとフィールド背景色を描き直し、他の入力フィールドとトーンを揃えます。23フレーム中18フレームで動き、累積面積は3.485%、平均強度は131.6でした。'showPicker' in HTMLInputElement.prototype という一行の真偽値だけで、ボタンの挙動がまるごと切り替わります。
// 標準のshowPicker()メソッドに対応する環境ではボタンが本物のカレンダーを開き、対応していない環境では入力欄にフォーカスするだけ
var hasShowPicker = 'showPicker' in HTMLInputElement.prototype;
pickBtn.addEventListener('click', function () {
human();
if (hasShowPicker && !wrap.classList.contains('force-fallback')) {
try { dateInput.showPicker(); } catch (err) { dateInput.focus({ preventScroll: true }); }
} else {
dateInput.focus({ preventScroll: true });
}
});

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="ringWrap">
<svg class="ring" viewBox="0 0 72 72" aria-hidden="true">
<circle class="ringTrack" cx="36" cy="36" r="30"></circle>
<circle class="ringFill" id="ringFill" cx="36" cy="36" r="30"></circle>
</svg>
<span class="ringNum" id="ringNum">0</span>
</div>
<div class="row">
<input class="dateInput" id="dateInput" type="date" value="2026-10-01">
<button class="pickBtn" id="pickBtn" type="button">カレンダーを開く</button>
</div>
<div class="foot">
<span class="daysLabel" id="daysLabel">D-9</span>
<button class="simToggle" id="simToggle" type="button" aria-pressed="false">フォールバックを再現</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 daysBetween()/CIRC 에 있다 — 실제 원 반지름에서 둘레를 구하고 남은 일수
// 비율을 stroke-dashoffset 에 반영한다(하드코딩 각도 아님). 여기는 링·입력·버튼의 모양만 맡는다.
@include stage($stage-yellow);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.ringWrap {
position: relative;
width: 72px;
height: 72px;
}
.ring {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ringTrack {
fill: none;
stroke: rgba($subject-ink, .16);
stroke-width: 6;
}
.ringFill {
fill: none;
stroke: $subject-blue;
stroke-width: 6;
stroke-linecap: round;
transition: stroke-dashoffset $dur-enter $ease-out;
}
.ringNum {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: $subject-ink;
font-size: 18px;
font-weight: 700;
}
.row {
display: flex;
align-items: center;
gap: $sp-2;
}
.dateInput {
border: none;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
padding: $sp-1 $sp-2;
font-size: 12px;
color-scheme: light;
// 폴백: 표준 픽커 아이콘을 모르는 환경도 입력 자체는 그대로 쓸 수 있다 — 배경만 살짝 맞춘다
&::-webkit-calendar-picker-indicator {
border-radius: $r-xs;
padding: $sp-1;
cursor: pointer;
}
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.pickBtn {
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.95); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.foot {
display: flex;
align-items: center;
gap: $sp-2;
}
.daysLabel {
color: $subject-ink;
font-size: 11px;
font-weight: 700;
background: rgba($stage-paper, .7);
padding: $sp-1 $sp-2;
border-radius: $r-xs;
}
.simToggle {
appearance: none;
border: none;
border-radius: $r-pill;
background: rgba($subject-ink, .82);
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
&[aria-pressed="true"] { background: $subject-orange-deep; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.ringWrap { width: 60px; height: 60px; }
.ringNum { font-size: 15px; }
.dateInput { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ringFill { transition: none; }
.pickBtn, .simToggle { transition: none; }
}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);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ringWrap {
position: relative;
width: 72px;
height: 72px;
}
.ring {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ringTrack {
fill: none;
stroke: rgba(23, 20, 26, 0.16);
stroke-width: 6;
}
.ringFill {
fill: none;
stroke: #2f6df6;
stroke-width: 6;
stroke-linecap: round;
transition: stroke-dashoffset 600ms ease-out;
}
.ringNum {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #17141a;
font-size: 18px;
font-weight: 700;
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
.dateInput {
border: none;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
padding: 4px 8px;
font-size: 12px;
color-scheme: light;
}
.dateInput::-webkit-calendar-picker-indicator {
border-radius: 4px;
padding: 4px;
cursor: pointer;
}
.dateInput:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.pickBtn {
appearance: none;
border: none;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.pickBtn:active {
transform: scale(0.95);
}
.pickBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.foot {
display: flex;
align-items: center;
gap: 8px;
}
.daysLabel {
color: #17141a;
font-size: 11px;
font-weight: 700;
background: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 4px;
}
.simToggle {
appearance: none;
border: none;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff7e6;
padding: 4px 12px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.simToggle:active {
transform: scale(0.96);
}
.simToggle[aria-pressed=true] {
background: #c93000;
}
.simToggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.ringWrap {
width: 60px;
height: 60px;
}
.ringNum {
font-size: 15px;
}
.dateInput {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ringFill {
transition: none;
}
.pickBtn, .simToggle {
transition: none;
}
}(function () {
var RANGE_DAYS = 30; // 링이 가득 찰 때 기준이 되는 최대 남은 일수
var REFERENCE_DATE = new Date('2026-09-22T00:00:00');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var dateInput = document.getElementById('dateInput');
var pickBtn = document.getElementById('pickBtn');
var ringFill = document.getElementById('ringFill');
var ringNum = document.getElementById('ringNum');
var daysLabel = document.getElementById('daysLabel');
var isDemo = true;
var CIRC = 2 * Math.PI * 30; // 실제 반지름에서 계산한 둘레 — 하드코딩 아님
ringFill.style.strokeDasharray = String(CIRC);
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function daysBetween(a, b) {
var ms = b.getTime() - a.getTime();
return Math.round(ms / 86400000);
}
function render() {
var picked = new Date(dateInput.value + 'T00:00:00');
var days = daysBetween(REFERENCE_DATE, picked);
var ratio = Math.max(0, Math.min(1, days / RANGE_DAYS));
ringFill.style.strokeDashoffset = String(CIRC * (1 - ratio));
ringNum.textContent = String(days);
daysLabel.textContent = days >= 0 ? ('D-' + days) : ('D+' + Math.abs(days));
}
render();
dateInput.addEventListener('change', function () { human(); render(); });
// 표준 showPicker() 를 아는 환경이면 버튼이 진짜 달력을 띄우고, 모르는 환경은 입력에 초점만 준다
var hasShowPicker = 'showPicker' in HTMLInputElement.prototype;
pickBtn.addEventListener('click', function () {
human();
if (hasShowPicker && !wrap.classList.contains('force-fallback')) {
try { dateInput.showPicker(); } catch (err) { dateInput.focus({ preventScroll: true }); }
} else {
dateInput.focus({ preventScroll: true });
}
});
var simToggle = document.getElementById('simToggle');
var simOn = false;
function setSim(on) {
simOn = on;
wrap.classList.toggle('force-fallback', on);
simToggle.setAttribute('aria-pressed', String(on));
pickBtn.textContent = on ? '入力欄フォーカスのみ' : 'カレンダーを開く';
}
simToggle.addEventListener('click', function () { human(); setSim(!simOn); });
var DATES = ['2026-10-01', '2026-10-08', '2026-09-25', '2026-11-05', '2026-09-23'];
var di = 0;
var last = 0;
var STEP_MS = 320;
var phase = 0;
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
phase = (phase + 1) % 3;
if (phase !== 1) {
dateInput.value = DATES[di % DATES.length];
di += 1;
render();
} else {
setSim(!simOn);
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// DateInputStyling — vanilla 판(run/375/demo/09-compat-date-input-styling/index.html) 의 React 포트.
// 진짜 input[type=date] 를 그대로 쓰고, 남은 일수 비율을 원 둘레의 stroke-dashoffset 로 잡아 보여준다.
import { useEffect, useRef } from "react";
import styles from "./DateInputStyling.module.scss";
type Box<T> = { current: T };
const RANGE_DAYS = 30; // 링이 가득 찰 때 기준이 되는 최대 남은 일수
const REFERENCE_DATE = new Date("2026-09-22T00:00:00");
const STEP_MS = 320;
export default function DateInputStyling() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const dateInputRef = useRef(null) as unknown as Box<HTMLInputElement>;
const pickBtnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const ringFillRef = useRef(null) as unknown as Box<SVGCircleElement>;
const ringNumRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const daysLabelRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const simToggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const dateInput = dateInputRef.current;
const pickBtn = pickBtnRef.current;
const ringFill = ringFillRef.current;
const ringNum = ringNumRef.current;
const daysLabel = daysLabelRef.current;
const simToggle = simToggleRef.current;
if (!wrap || !dateInput || !pickBtn || !ringFill || !ringNum || !daysLabel || !simToggle) return;
let isDemo = true;
const CIRC = 2 * Math.PI * 30; // 실제 반지름에서 계산한 둘레 — 하드코딩 아님
ringFill.style.strokeDasharray = String(CIRC);
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function daysBetween(a: Date, b: Date) {
const ms = b.getTime() - a.getTime();
return Math.round(ms / 86400000);
}
function render() {
const picked = new Date(dateInput!.value + "T00:00:00");
const days = daysBetween(REFERENCE_DATE, picked);
const ratio = Math.max(0, Math.min(1, days / RANGE_DAYS));
ringFill!.style.strokeDashoffset = String(CIRC * (1 - ratio));
ringNum!.textContent = String(days);
daysLabel!.textContent = days >= 0 ? ("D-" + days) : ("D+" + Math.abs(days));
}
render();
function onChange() { human(); render(); }
dateInput.addEventListener("change", onChange);
// 표준 showPicker() 를 아는 환경이면 버튼이 진짜 달력을 띄우고, 모르는 환경은 입력에 초점만 준다
const hasShowPicker = "showPicker" in HTMLInputElement.prototype;
function onPickClick() {
human();
if (hasShowPicker && !wrap!.classList.contains(styles.forceFallback)) {
try { dateInput!.showPicker(); } catch (err) { dateInput!.focus({ preventScroll: true }); }
} else {
dateInput!.focus({ preventScroll: true });
}
}
pickBtn.addEventListener("click", onPickClick);
let simOn = false;
function setSim(on: boolean) {
simOn = on;
wrap!.classList.toggle(styles.forceFallback, on);
simToggle!.setAttribute("aria-pressed", String(on));
pickBtn!.textContent = on ? "달력 초점만" : "달력 열기";
}
function onSimClick() { human(); setSim(!simOn); }
simToggle.addEventListener("click", onSimClick);
const DATES = ["2026-10-01", "2026-10-08", "2026-09-25", "2026-11-05", "2026-09-23"];
let di = 0;
let last = 0;
let phase = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
phase = (phase + 1) % 3;
if (phase !== 1) {
dateInput!.value = DATES[di % DATES.length];
di += 1;
render();
} else {
setSim(!simOn);
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
dateInput!.removeEventListener("change", onChange);
pickBtn!.removeEventListener("click", onPickClick);
simToggle!.removeEventListener("click", onSimClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.ringWrap}>
<svg className={styles.ring} viewBox="0 0 72 72" aria-hidden="true">
<circle className={styles.ringTrack} cx="36" cy="36" r="30"></circle>
<circle className={styles.ringFill} ref={ringFillRef} cx="36" cy="36" r="30"></circle>
</svg>
<span className={styles.ringNum} ref={ringNumRef}>0</span>
</div>
<div className={styles.row}>
<input className={styles.dateInput} ref={dateInputRef} type="date" defaultValue="2026-10-01" />
<button className={styles.pickBtn} ref={pickBtnRef} type="button">달력 열기</button>
</div>
<div className={styles.foot}>
<span className={styles.daysLabel} ref={daysLabelRef}>D-9</span>
<button className={styles.simToggle} ref={simToggleRef} type="button" aria-pressed="false">폴백 흉내내기</button>
</div>
</div>
</main>
);
}09 · 日付入力のカスタム — 標準のカレンダーアイコンとフィールド背景を描き直す日付選択 — 計測 累積面積 3.485% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
値がフォールバックを変える基準 — 対応可否・数値・順序
01 スクロールバー
::-webkit-scrollbar 系の擬似要素ルールが先に来て、@supports (scrollbar-width: thin) が後ろから上書きします。宣言の並び順そのものが優先順位を決めています。
02 背景ぼかし
--fallback-alpha カスタムプロパティが半透明背景の濃さを決め、@supports の条件は backdrop-filter と -webkit-backdrop-filter のどちらかへの対応です。
03 動的ビューポート高さ
VH_RECALC_DEBOUNCE_MS(既定150ms)が、アドレスバーの状態変化から高さ再計算までの遅延を決めます。固定版はこの値を一切参照せず、動かないままです。
04 入力拡大ロック
LOCK_FONT_PX としきい値比較した結果が拡大率になります。現在のフォントサイズがこの値以上であれば、比率は1のまま保たれます。
05 フレックス間隔
gap の対応可否だけが分岐条件で、対応する環境では margin-right が0に戻り、gap の値がそのまま間隔を作ります。
06 機能対応分岐
FEATURES 配列の5つの test 関数の戻り値が、対応・非対応のラベル表示をそれぞれ独立に決めます。
07 引っ張りロック
OVERSCROLL_LOCK_PX が引っ張れる最大量を決め、deltaY * 0.4 という係数が指の動きに対する追従の重さを決めます。
08 文字レンダリング補正
clip-path の --split 変数がスライダーの位置を反映し、境界の左右で .is-raw と .is-fixed の見た目を切り替えます。
09 日付入力
hasShowPicker の真偽値ひとつが、ボタンクリック時の挙動(カレンダーを開くか、フォーカスするだけか)を丸ごと決めます。
どこで壊れるか — 古い環境、順序ミス、reduced-motion
css フォールバックを書くときにいちばん壊れやすいのは、宣言の順番です。01・02・05・08のように「まず常に効く見た目を書き、その下に @supports で上書きする」という順序を逆にしてしまうと、対応環境のルールが先に読み込まれてしまい、後から来たフォールバック宣言がそれを打ち消してしまいます。同じ理由で、:not(.force-fallback) のようなクラス条件を @supports の中に入れ忘れると、デモの「フォールバックを試す」ボタンが実際には何も切り替えなくなります。
そして、この記事を読み進めてもうkda6g7zxという言葉が次に来るだけの位置まで来ています——同じパスワードで9つのフォルダがまとめて開きます。もし文字が読みにくいと感じたら、それは仕掛けではなく、あなたがここまで本文を読んだ証拠です。
reduced-motionが残すもの・消すもの
prefers-reduced-motion: reduce が有効な環境では、9つとも自動ループが止まり、色や太さの切り替え用トランジションが軒並み none になります。ただし01だけは例外です。トラックの点(trackDot)の位置は shown というlerp変数をJavaScriptで毎フレーム進めて直接反映しており、CSSの transition を一切使っていません。そのため01のreduced-motionブロックにある .trackDot { transition: none; } は、もともと存在しないトランジションを念のため無効化しているだけで、他の8つのように「なめらかな変化を瞬時の変化に変える」働きはしていません。
3つのバリエーションとアクセシビリティ
| 名前 | 変えた値 | 感じ方 |
|---|---|---|
| フォールバック背景を濃く | FALLBACK_BG_ALPHA 0.6 → 0.85 | ぼかし効果がない環境でカード背景がより濃く満ちる |
| ビューポート再計算を速く | VH_RECALC_DEBOUNCE_MS 150 → 50 | アドレスバーが動いた後により早く高さの値が再計算される |
| 引っ張りロックの範囲を広げる | OVERSCROLL_LOCK_PX 24 → 48 | リストをより強く引っ張らないとロックが外れない |
9つの simToggle ボタンはすべて aria-pressed を持ち、押した状態をスクリーンリーダーに伝えます。08の太さ調整ハンドルには role="slider" と aria-valuemin・aria-valuemax・aria-valuenow が付いていて、動かすたびに現在値が更新されます。ボタンやハンドルには :focus-visible の輪郭線が全項目に用意されていて、キーボード操作時だけ見えるようになっています。css フォールバックの分岐そのものは視覚効果の話ですが、操作できるかどうかは別の話として、こうした属性で別途担保しています。
FAQ
ビルド手順やフレームワークがないと使えませんか?
いいえ。9つとも素のCSSとバニラJavaScriptだけで書かれていて、.scss は変数展開のためにコンパイルしているだけです。zipの中身をそのままページに貼り付ければ動きます。
あるcss フォールバック値が実際に使われているかどうかは、どう確かめられますか?
各項目の「フォールバックを試す」トグルを押すと force-fallback クラスが付き、@supports の条件を満たしていてもフォールバック側の見た目に強制的に切り替わります。そのまま元の見た目に戻れば、フォールバック宣言が正しく効いている証拠です。
css フォント フォールバックが08だけ極端に大きな数値になっているのはなぜですか?
08の平均強度196.5は9つの中で最大で、動いたフレームも23/24とほぼ全フレームです。太さと字間を同時に補正しながらスライダーの clip-path 境界も毎フレーム動くため、ピクセルの変化量が他の項目より単純に多くなります。
出典として、@supportsの定義・gapプロパティ・ビューポート単位の説明・overscroll-behaviorの定義・scrollbar-colorの定義を参照しました。
半透明の背景やぼかしをもっと試したい方はCSS グラスモーフィズム効果9選【コピペOK】、日付の入力欄まわりをさらに広げたい方はcss 日付選択 9選 — ポップオーバーで開閉する9つの方式、スクロール位置を見せる仕組みに興味がある方はスクロール インジケーター 9選 — 現在地がすぐわかるもあわせてご覧ください。









