CSS グラスモーフィズム効果9選【コピペOK】
画面に半透明のすりガラス板を重ねたように、後ろの背景がぼんやり透けて見えるのに文字はくっきり読めるデザインを、CSS グラスモーフィズム(リキッドガラス)と呼びます。カードやナビゲーション、ボタンからトグルまで、9種類のガラス演出を一行でコピペOKな形でまとめました。
01 glassmorphism-effects-frosted-card
02 glassmorphism-effects-glass-nav
03 glassmorphism-effects-liquid-glass-btn
04 glassmorphism-effects-glass-blob-bg
05 glassmorphism-effects-glass-modal
06 glassmorphism-effects-refraction-hover
07 glassmorphism-effects-glass-tab
08 glassmorphism-effects-glass-toggle
09 glassmorphism-effects-glass-input
自動再生 · タイルを押すとその項目へ · すべて1つのzip
9個は人気順ではなく、画面の中でガラス素材に触れていく順番に並べています。触れなくてもガラスに見えるもの(01のカード・02のナビゲーション)や、触れなくても動き続けるもの(04の背景)から始まり、指を近づけると反応するもの(03のボタン・06の光沢)を経て、クリックで開閉するもの(05のモーダル)、そして選ぶ・切り替える・入力するコントロール(07のタブ・08のトグル・09の入力欄)で締めくくります。ガラス板の中に写真を流して見せるならCSS画像スライドアニメーションがその相手で、成功を祝う演出を置くなら紙吹雪のアニメーションが後に続きます。
01フロストカード
すりガラス 風 デザイン をどこで使うか
ポートフォリオやランディングページの紹介カードにbackdrop-filter: blur(16px) saturate(160%)と半透明の背景を重ねると、後ろに置いた色の滲みがぼんやり透けて、カードがガラス板のように浮いて見えます。

<main class="stage">
<div class="fc-scene is-demo">
<span class="fc-blob fc-blob--a"></span>
<span class="fc-blob fc-blob--b"></span>
<div class="fc-card fc-focal">
<span class="fc-card__eyebrow">Featured</span>
<span class="fc-card__title">Aurora Plan</span>
<span class="fc-card__desc">Glass surface, real blur</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.fc-scene {
position: relative;
width: min(340px, 90vw);
aspect-ratio: 340 / 190;
display: grid;
place-items: center;
overflow: hidden; // 320px 에서 아래 블러 얼룩이 음수 offset 으로 장면 밖까지 번져 body.scrollHeight 를 넘겼다 (실측 2026-09-10)
}
// 유리 카드 뒤에서 블러될 색 얼룩 — .stage 배경은 그대로 단색, 이 레이어만 그라디언트 역할을 한다
.fc-blob {
position: absolute;
width: 160px;
height: 160px;
border-radius: 50%;
filter: blur(30px);
opacity: .75;
}
.fc-blob--a { background: $color; top: -30px; left: -20px; }
.fc-blob--b { background: $subject-cream; bottom: -40px; right: -10px; }
.fc-card {
position: relative;
z-index: 1;
box-sizing: border-box;
width: min(220px, 80%);
padding: $sp-6 $sp-5;
border-radius: $r-card;
background: rgba(255, 255, 255, .14);
border: 1px solid rgba(255, 255, 255, .35);
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
box-shadow: $shadow-press;
display: flex;
flex-direction: column;
gap: $sp-1;
color: $subject-cream;
font: 600 13px/1.4 -apple-system, "Segoe UI", sans-serif;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.fc-card__eyebrow { font-size: 11px; letter-spacing: .02em; } // opacity .75 는 파란 얼룩 위 3.31:1 이라 뺐다 — AA 4.5
.fc-card__title { font-size: 18px; font-weight: 700; }
.fc-card__desc { font-size: 12px; opacity: .8; font-weight: 500; }
// 실사용: 진짜 마우스 호버로 트랜지션
.fc-focal:hover {
transform: scale(1.03);
box-shadow: $shadow-raised;
}
// 데모 루프: is-demo 는 같은 모양을 keyframes 로 무한 재생 (헤드리스는 :hover 를 못 건다)
@keyframes fc-breathe {
0%, 100% { transform: scale(1); box-shadow: $shadow-press; }
50% { transform: scale(1.03); box-shadow: $shadow-raised; }
}
.fc-scene.is-demo .fc-focal { animation: fc-breathe $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fc-focal:hover,
.fc-scene.is-demo .fc-focal { animation: none !important; transform: none !important; box-shadow: $shadow-press !important; }
}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);
}
.fc-scene {
position: relative;
width: min(340px, 90vw);
aspect-ratio: 340/190;
display: grid;
place-items: center;
overflow: hidden;
}
.fc-blob {
position: absolute;
width: 160px;
height: 160px;
border-radius: 50%;
filter: blur(30px);
opacity: 0.75;
}
.fc-blob--a {
background: #2f6df6;
top: -30px;
left: -20px;
}
.fc-blob--b {
background: #fff7e6;
bottom: -40px;
right: -10px;
}
.fc-card {
position: relative;
z-index: 1;
box-sizing: border-box;
width: min(220px, 80%);
padding: 24px 20px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.35);
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
display: flex;
flex-direction: column;
gap: 4px;
color: #fff7e6;
font: 600 13px/1.4 -apple-system, "Segoe UI", sans-serif;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fc-card__eyebrow {
font-size: 11px;
letter-spacing: 0.02em;
}
.fc-card__title {
font-size: 18px;
font-weight: 700;
}
.fc-card__desc {
font-size: 12px;
opacity: 0.8;
font-weight: 500;
}
.fc-focal:hover {
transform: scale(1.03);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
@keyframes fc-breathe {
0%, 100% {
transform: scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
50% {
transform: scale(1.03);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
.fc-scene.is-demo .fc-focal {
animation: fc-breathe 300ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.fc-focal:hover,
.fc-scene.is-demo .fc-focal {
animation: none !important;
transform: none !important;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32) !important;
}
}import { CSSProperties } from "react";
import styles from "./FrostedCard.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface FrostedCardProps {
duration?: string;
easing?: string;
color?: string;
}
export default function FrostedCard({
duration = "300ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: FrostedCardProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.scene} style={style}>
<span className={styles.blobA} />
<span className={styles.blobB} />
<div className={styles.card}>
<span className={styles.eyebrow}>Featured</span>
<span className={styles.title}>Aurora Plan</span>
<span className={styles.desc}>Glass surface, real blur</span>
</div>
</div>
);
}01 · フロストカード — 計測 累積面積 4.222% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
何が肝心か
マウスを乗せるとカードがわずかに拡大し、影も深くなります。
.fc-card {
background: rgba(255, 255, 255, .14);
border: 1px solid rgba(255, 255, 255, .35);
backdrop-filter: blur(16px) saturate(160%);
box-shadow: $shadow-press;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.fc-focal:hover {
transform: scale(1.03);
box-shadow: $shadow-raised;
}
02ガラスナビ
いつ使うか
半透明のガラス質ナビゲーションバーの中で、選択中のメニューの後ろをぼやけたガラスのピルがtranslateXで移動します。

<main class="stage">
<nav class="gn-nav is-demo" aria-label="demo">
<span class="gn-pill" aria-hidden="true"></span>
<a class="gn-link" href="#">Home</a>
<a class="gn-link" href="#">Docs</a>
<a class="gn-link" href="#">Pricing</a>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-yellow);
// 라벨 폭을 고정값으로 둔다 — translateX 만 쓰고 scaleX 는 안 쓰므로 BD19(flex 분배) 대상은 아니지만,
// 실제 폭이 흔들리면 필이 라벨 밖으로 걸리는 건 매한가지라 flex:0 0 auto + 고정 width 로 고정한다
$w: 76px;
.gn-nav {
position: relative;
box-sizing: border-box;
width: min(272px, 86vw);
display: flex;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .14);
border: 1px solid rgba(255, 255, 255, .3);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
box-shadow: $shadow-press;
}
.gn-link {
position: relative;
z-index: 1;
flex: 0 0 auto;
box-sizing: border-box;
width: $w;
text-align: center;
padding: $sp-2 0;
border-radius: $r-pill;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
color: $subject-ink;
text-decoration: none;
}
.gn-pill {
position: absolute;
top: $sp-2; left: $sp-2; bottom: $sp-2;
box-sizing: border-box;
width: $w;
border-radius: $r-pill;
background: rgba(255, 255, 255, .6);
backdrop-filter: blur(6px);
box-shadow: $shadow-press;
transform: translateX(0);
transition: transform $duration $easing;
}
// 실사용: 진짜 호버로 필이 그 링크 아래로 이동 (:has 로 형제 상태를 읽는다)
.gn-nav:has(.gn-link:nth-child(3):hover) .gn-pill { transform: translateX(calc(#{$w} + #{$sp-2})); }
.gn-nav:has(.gn-link:nth-child(4):hover) .gn-pill { transform: translateX(calc((#{$w} + #{$sp-2}) * 2)); }
// 데모 루프: is-demo 는 세 자리를 keyframes 로 오간다 (헤드리스는 :has(:hover) 를 못 건다)
@keyframes gn-loop {
0%, 15% { transform: translateX(0); }
33%, 48% { transform: translateX(calc(#{$w} + #{$sp-2})); }
66%, 81% { transform: translateX(calc((#{$w} + #{$sp-2}) * 2)); }
100% { transform: translateX(0); }
}
.gn-nav.is-demo .gn-pill { animation: gn-loop $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.gn-pill { transition: none; }
.gn-nav.is-demo .gn-pill { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gn-nav {
position: relative;
box-sizing: border-box;
width: min(272px, 86vw);
display: flex;
gap: 8px;
padding: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.gn-link {
position: relative;
z-index: 1;
flex: 0 0 auto;
box-sizing: border-box;
width: 76px;
text-align: center;
padding: 8px 0;
border-radius: 999px;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
color: #17141a;
text-decoration: none;
}
.gn-pill {
position: absolute;
top: 8px;
left: 8px;
bottom: 8px;
box-sizing: border-box;
width: 76px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(6px);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateX(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.gn-nav:has(.gn-link:nth-child(3):hover) .gn-pill {
transform: translateX(calc(76px + 8px));
}
.gn-nav:has(.gn-link:nth-child(4):hover) .gn-pill {
transform: translateX(calc((76px + 8px) * 2));
}
@keyframes gn-loop {
0%, 15% {
transform: translateX(0);
}
33%, 48% {
transform: translateX(calc(76px + 8px));
}
66%, 81% {
transform: translateX(calc((76px + 8px) * 2));
}
100% {
transform: translateX(0);
}
}
.gn-nav.is-demo .gn-pill {
animation: gn-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.gn-pill {
transition: none;
}
.gn-nav.is-demo .gn-pill {
animation: none !important;
}
}import { CSSProperties } from "react";
import styles from "./GlassNavbar.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
};
export interface GlassNavbarProps {
duration?: string;
easing?: string;
}
const LINKS = ["Home", "Docs", "Pricing"];
export default function GlassNavbar({
duration = "220ms",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
}: GlassNavbarProps) {
const style: Vars = { "--duration": duration, "--easing": easing };
return (
<nav className={styles.nav} style={style} aria-label="glass nav">
<span className={styles.pill} aria-hidden="true" />
{LINKS.map((label) => (
<a key={label} className={styles.link} href="#">
{label}
</a>
))}
</nav>
);
}02 · ガラスナビ — 計測 累積面積 6.783% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
押さえておく点
各リンクの幅を固定しているため、文字数が変わってもピルは正確な位置で止まります。
.gn-link { flex: 0 0 auto; width: $w; }
.gn-pill {
position: absolute;
width: $w;
background: rgba(255, 255, 255, .6);
transform: translateX(0);
transition: transform $duration $easing;
}
.gn-nav:has(.gn-link:nth-child(3):hover) .gn-pill {
transform: translateX(calc(#{$w} + #{$sp-2}));
}
03屈折ハイライトボタン
どの画面か
ボタンのガラス面に細い光の帯を::beforeで重ねておき、マウスを乗せるとその帯がtranslateXとskewXでボタンを一度だけ横切り、リキッドガラスが屈折するようにきらめきます。

<main class="stage">
<button class="lb-btn lb-focal is-demo" type="button">
<span class="lb-btn__label">Get started</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
.lb-btn {
position: relative;
overflow: hidden;
box-sizing: border-box;
padding: $sp-3 $sp-8;
border: 1px solid rgba(255, 255, 255, .4);
border-radius: $r-control;
background: rgba(255, 255, 255, .16);
backdrop-filter: blur(12px) saturate(160%);
-webkit-backdrop-filter: blur(12px) saturate(160%);
box-shadow: $shadow-press;
color: $subject-ink;
font: 700 14px/1 -apple-system, "Segoe UI", sans-serif;
cursor: pointer;
}
.lb-btn__label { position: relative; z-index: 1; }
// 굴절 하이라이트 — 좁은 빛줄기 레이어. transform 만 움직인다 (top/left 는 고정)
.lb-btn::before {
content: "";
position: absolute;
top: -20%; left: -40%;
width: 40%; height: 140%;
background: linear-gradient(100deg, transparent, $color, transparent);
opacity: .8;
transform: translateX(-40%) skewX(-18deg);
transition: transform $duration $easing;
}
// 실사용: 진짜 마우스 호버로 빛줄기가 한 번 지나간다
.lb-focal:hover::before { transform: translateX(340%) skewX(-18deg); }
// 데모 루프: is-demo 는 같은 이동을 keyframes 로 반복
@keyframes lb-sweep {
0%, 20% { transform: translateX(-40%) skewX(-18deg); }
60%, 100% { transform: translateX(340%) skewX(-18deg); }
}
.lb-btn.is-demo::before { animation: lb-sweep $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.lb-focal:hover::before,
.lb-btn.is-demo::before { animation: none !important; transition: none !important; transform: translateX(-40%) skewX(-18deg) !important; }
}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);
}
.lb-btn {
position: relative;
overflow: hidden;
box-sizing: border-box;
padding: 12px 32px;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 12px;
background: rgba(255, 255, 255, 0.16);
backdrop-filter: blur(12px) saturate(160%);
-webkit-backdrop-filter: blur(12px) saturate(160%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font: 700 14px/1 -apple-system, "Segoe UI", sans-serif;
cursor: pointer;
}
.lb-btn__label {
position: relative;
z-index: 1;
}
.lb-btn::before {
content: "";
position: absolute;
top: -20%;
left: -40%;
width: 40%;
height: 140%;
background: linear-gradient(100deg, transparent, #fff7e6, transparent);
opacity: 0.8;
transform: translateX(-40%) skewX(-18deg);
transition: transform 600ms ease-out;
}
.lb-focal:hover::before {
transform: translateX(340%) skewX(-18deg);
}
@keyframes lb-sweep {
0%, 20% {
transform: translateX(-40%) skewX(-18deg);
}
60%, 100% {
transform: translateX(340%) skewX(-18deg);
}
}
.lb-btn.is-demo::before {
animation: lb-sweep 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.lb-focal:hover::before,
.lb-btn.is-demo::before {
animation: none !important;
transition: none !important;
transform: translateX(-40%) skewX(-18deg) !important;
}
}import { CSSProperties } from "react";
import styles from "./LiquidGlassButton.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface LiquidGlassButtonProps {
duration?: string;
easing?: string;
color?: string;
}
export default function LiquidGlassButton({
duration = "600ms",
easing = "ease-out",
color = "#fff7e6",
}: LiquidGlassButtonProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<button className={styles.btn} style={style} type="button">
<span className={styles.label}>Get started</span>
</button>
);
}03 · 屈折ハイライトボタン — 計測 累積面積 3.422% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どう作るか
.lb-btn::before {
content: "";
position: absolute;
top: -20%; left: -40%;
width: 40%; height: 140%;
background: linear-gradient(100deg, transparent, $color, transparent);
transform: translateX(-40%) skewX(-18deg);
transition: transform $duration $easing;
}
.lb-focal:hover::before {
transform: translateX(340%) skewX(-18deg);
}
04動くブロブ背景
何が動くか
ガラスパネルの後ろで色の違うぼやけたブロブが2つ、transform: translate()で位置をゆっくり変えながら常に漂い続け、パネルのbackdrop-filterがその動きを淡く映し出します。

<main class="stage">
<div class="bb-scene">
<span class="bb-blob bb-blob--a"></span>
<span class="bb-blob bb-blob--b"></span>
<div class="bb-panel">
<span class="bb-panel__title">Live</span>
<span class="bb-panel__desc">Motion blurs through glass</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue;
@include stage($stage-paper);
.bb-scene {
position: relative;
width: min(340px, 90vw);
aspect-ratio: 340 / 190;
overflow: hidden;
border-radius: $r-card;
}
// 블롭 두 개는 배경(무대)이 아니라 이 안의 장식 레이어다 — .stage 자체 배경은 여전히 단색
// blur(40px) 는 인접 프레임 간 픽셀차를 문턱(12) 바로 아래로 뭉갰다(실측: 모든 프레임 쌍이 11~12) —
// 블러를 26px 로 낮추고 이동 폭을 키워 프레임마다 확실히 문턱을 넘게 했다
.bb-blob {
position: absolute;
width: 170px;
height: 170px;
border-radius: 50%;
filter: blur(26px);
opacity: .88;
}
.bb-blob--a { background: $color; top: -40px; left: -30px; animation: bb-drift-a $duration $easing infinite; }
.bb-blob--b { background: $stage-orange; bottom: -50px; right: -20px; animation: bb-drift-b $duration $easing infinite; }
@keyframes bb-drift-a {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(110px, 70px); }
}
@keyframes bb-drift-b {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-90px, -55px); }
}
.bb-panel {
position: absolute;
inset: auto $sp-4 $sp-4 $sp-4;
box-sizing: border-box;
padding: $sp-4;
border-radius: $r-control;
background: rgba(255, 255, 255, .22);
border: 1px solid rgba(255, 255, 255, .4);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
box-shadow: $shadow-press;
display: flex;
flex-direction: column;
gap: $sp-1;
color: $subject-ink;
font: 600 12px/1.3 -apple-system, "Segoe UI", sans-serif;
}
.bb-panel__title { font-weight: 700; font-size: 13px; }
.bb-panel__desc { opacity: .75; }
@media (prefers-reduced-motion: reduce) {
.bb-blob { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bb-scene {
position: relative;
width: min(340px, 90vw);
aspect-ratio: 340/190;
overflow: hidden;
border-radius: 16px;
}
.bb-blob {
position: absolute;
width: 170px;
height: 170px;
border-radius: 50%;
filter: blur(26px);
opacity: 0.88;
}
.bb-blob--a {
background: #2f6df6;
top: -40px;
left: -30px;
animation: bb-drift-a 2s linear infinite;
}
.bb-blob--b {
background: #ff4d1f;
bottom: -50px;
right: -20px;
animation: bb-drift-b 2s linear infinite;
}
@keyframes bb-drift-a {
0%, 100% {
transform: translate(0, 0);
}
50% {
transform: translate(110px, 70px);
}
}
@keyframes bb-drift-b {
0%, 100% {
transform: translate(0, 0);
}
50% {
transform: translate(-90px, -55px);
}
}
.bb-panel {
position: absolute;
inset: auto 16px 16px 16px;
box-sizing: border-box;
padding: 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.22);
border: 1px solid rgba(255, 255, 255, 0.4);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
display: flex;
flex-direction: column;
gap: 4px;
color: #17141a;
font: 600 12px/1.3 -apple-system, "Segoe UI", sans-serif;
}
.bb-panel__title {
font-weight: 700;
font-size: 13px;
}
.bb-panel__desc {
opacity: 0.75;
}
@media (prefers-reduced-motion: reduce) {
.bb-blob {
animation: none !important;
}
}import { CSSProperties } from "react";
import styles from "./GlassBlobBackground.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--color"?: string;
};
export interface GlassBlobBackgroundProps {
duration?: string;
color?: string;
}
export default function GlassBlobBackground({
duration = "2000ms",
color = "#2f6df6",
}: GlassBlobBackgroundProps) {
const style: Vars = { "--duration": duration, "--color": color };
return (
<div className={styles.scene} style={style}>
<span className={`${styles.blob} ${styles.blobA}`} />
<span className={`${styles.blob} ${styles.blobB}`} />
<div className={styles.panel}>
<span className={styles.panelTitle}>Live</span>
<span className={styles.panelDesc}>Motion blurs through glass</span>
</div>
</div>
);
}04 · 動くブロブ背景 — 計測 累積面積 41.944% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
.bb-blob { border-radius: 50%; filter: blur(26px); }
.bb-blob--a { animation: bb-drift-a $duration $easing infinite; }
@keyframes bb-drift-a {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(110px, 70px); }
}
05ガラスモーダル
どこで使うか
確認ボタンを押すとガラス質のモーダルパネルがopacityとscaleで小さく畳まれた状態から広がって現れ、背後の画面はぼやけたまま透けて見えます。

<main class="stage">
<div class="gm-scene is-demo">
<input type="checkbox" id="gm-open" class="gm-check">
<label class="gm-trigger" for="gm-open">Delete item</label>
<div class="gm-overlay"></div>
<div class="gm-modal gm-focal">
<span class="gm-modal__title">Delete this file?</span>
<span class="gm-modal__desc">This can't be undone.</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 트리거는 위쪽에, 모달은 장면 전체를 덮는 오버레이 안에서 따로 중앙 정렬한다 —
// 처음엔 둘 다 grid place-items:center 로 같은 자리에 겹쳐 렌더됐다 (실측: poster.png 에서 글자가 포개짐)
.gm-scene {
position: relative;
box-sizing: border-box;
width: min(340px, 90vw);
aspect-ratio: 340 / 190;
display: flex;
justify-content: center;
padding-top: $sp-4;
}
.gm-check { position: absolute; opacity: 0; pointer-events: none; }
.gm-trigger {
position: relative;
z-index: 0;
align-self: flex-start;
box-sizing: border-box;
padding: $sp-2 $sp-4;
border-radius: $r-control;
background: rgba(255, 255, 255, .12);
border: 1px solid rgba(255, 255, 255, .3);
color: $subject-cream;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
cursor: pointer;
}
.gm-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, .35);
opacity: 0;
transition: opacity $duration $easing;
pointer-events: none;
}
.gm-modal {
position: absolute;
inset: 0;
margin: auto;
z-index: 2;
box-sizing: border-box;
width: min(210px, 74%);
height: fit-content;
padding: $sp-5 $sp-4;
border-radius: $r-card;
background: rgba(255, 255, 255, .18);
border: 1px solid rgba(255, 255, 255, .38);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
box-shadow: $shadow-raised;
display: flex;
flex-direction: column;
gap: $sp-2;
color: $subject-cream;
font: 600 12px/1.4 -apple-system, "Segoe UI", sans-serif;
opacity: 0;
transform: scale(.85);
transition: opacity $duration $easing, transform $duration $easing;
pointer-events: none;
}
.gm-modal__title { font-size: 14px; font-weight: 700; }
.gm-modal__desc { opacity: .8; font-weight: 500; }
// 실사용: 체크박스 상태만으로 오버레이 + 모달이 열린다
.gm-check:checked ~ .gm-overlay { opacity: 1; }
.gm-check:checked ~ .gm-modal { opacity: 1; transform: scale(1); }
// 데모 루프: is-demo 는 열림·닫힘을 keyframes 로 반복 (체크박스 상태와 무관한 별도 경로)
@keyframes gm-overlay-loop {
0%, 12% { opacity: 0; }
35%, 78% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes gm-modal-loop {
0%, 12% { opacity: 0; transform: scale(.85); }
35%, 78% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(.85); }
}
.gm-scene.is-demo .gm-overlay { animation: gm-overlay-loop $dur-loop $easing infinite; }
.gm-scene.is-demo .gm-modal { animation: gm-modal-loop $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.gm-overlay, .gm-modal { transition: none; }
.gm-scene.is-demo .gm-overlay,
.gm-scene.is-demo .gm-modal { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gm-scene {
position: relative;
box-sizing: border-box;
width: min(340px, 90vw);
aspect-ratio: 340/190;
display: flex;
justify-content: center;
padding-top: 16px;
}
.gm-check {
position: absolute;
opacity: 0;
pointer-events: none;
}
.gm-trigger {
position: relative;
z-index: 0;
align-self: flex-start;
box-sizing: border-box;
padding: 8px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #fff7e6;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
cursor: pointer;
}
.gm-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.35);
opacity: 0;
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
.gm-modal {
position: absolute;
inset: 0;
margin: auto;
z-index: 2;
box-sizing: border-box;
width: min(210px, 74%);
height: fit-content;
padding: 20px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.38);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: flex;
flex-direction: column;
gap: 8px;
color: #fff7e6;
font: 600 12px/1.4 -apple-system, "Segoe UI", sans-serif;
opacity: 0;
transform: scale(0.85);
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
.gm-modal__title {
font-size: 14px;
font-weight: 700;
}
.gm-modal__desc {
opacity: 0.8;
font-weight: 500;
}
.gm-check:checked ~ .gm-overlay {
opacity: 1;
}
.gm-check:checked ~ .gm-modal {
opacity: 1;
transform: scale(1);
}
@keyframes gm-overlay-loop {
0%, 12% {
opacity: 0;
}
35%, 78% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes gm-modal-loop {
0%, 12% {
opacity: 0;
transform: scale(0.85);
}
35%, 78% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.85);
}
}
.gm-scene.is-demo .gm-overlay {
animation: gm-overlay-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.gm-scene.is-demo .gm-modal {
animation: gm-modal-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.gm-overlay, .gm-modal {
transition: none;
}
.gm-scene.is-demo .gm-overlay,
.gm-scene.is-demo .gm-modal {
animation: none !important;
}
}// 체크박스가 진짜 열림·닫힘을 낸다. 실제로 누르면 데모 루프(.is-demo)만 뗀다.
document.querySelector('.gm-check').addEventListener('change', function () {
document.querySelector('.gm-scene').classList.remove('is-demo');
});import { CSSProperties, useState } from "react";
import styles from "./GlassModal.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
};
export interface GlassModalProps {
duration?: string;
easing?: string;
}
export default function GlassModal({
duration = "600ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
}: GlassModalProps) {
const [open, setOpen] = useState(false);
const style: Vars = { "--duration": duration, "--easing": easing };
return (
<div className={styles.scene} style={style}>
<button className={styles.trigger} type="button" onClick={() => setOpen(true)}>
Delete item
</button>
<div
className={`${styles.overlay} ${open ? styles.isOpen : ""}`}
onClick={() => setOpen(false)}
/>
<div className={`${styles.modal} ${open ? styles.isOpen : ""}`}>
<span className={styles.title}>Delete this file?</span>
<span className={styles.desc}>This can't be undone.</span>
</div>
</div>
);
}05 · ガラスモーダル — 計測 累積面積 14.35% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの要点
.gm-modal {
opacity: 0;
transform: scale(.85);
transition: opacity $duration $easing, transform $duration $easing;
}
.gm-check:checked ~ .gm-modal {
opacity: 1;
transform: scale(1);
}
06マウス追従の光沢
見えるもの
大きなガラスパネルの上でマウスを動かすと、座標を保存する--mx・--myカスタムプロパティに沿って楕円形の光沢がリアルタイムで追従し、ガラス面をなぞるような反射が生まれます。

<main class="stage">
<div class="rh-panel rh-focal is-demo">
<span class="rh-panel__title">Hover the glass</span>
<span class="rh-panel__desc">Specular light follows the cursor</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: linear;
$color: $subject-cream;
// 커서 좌표 — 퍼센트로 등록해야 is-demo 의 키프레임이 값을 보간한다
@property --mx { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
@property --my { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
@include stage($stage-yellow);
.rh-panel {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: min(300px, 86vw);
aspect-ratio: 300 / 190;
border-radius: $r-card;
background: rgba(255, 255, 255, .16);
border: 1px solid rgba(255, 255, 255, .4);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
box-shadow: $shadow-press;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: $sp-1;
padding: $sp-4;
color: $subject-ink;
font: 600 12px/1.3 -apple-system, "Segoe UI", sans-serif;
--mx: 50%;
--my: 35%;
}
.rh-panel__title { position: relative; z-index: 1; font-size: 13px; font-weight: 700; }
.rh-panel__desc { position: relative; z-index: 1; opacity: .75; font-weight: 500; }
// 실사용: 진짜 pointermove 가 --mx·--my 를 갱신한다(위 <script>)
.rh-panel::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(120px 80px at var(--mx) var(--my), $color 0%, transparent 70%);
opacity: .7;
pointer-events: none;
}
// 데모 루프: is-demo 는 --mx·--my 를 keyframes 로 원을 그리며 돌려 캡처 (실 JS 와 별개 경로, 공존 가능)
@keyframes rh-drift {
0% { --mx: 15%; --my: 20%; }
25% { --mx: 85%; --my: 15%; }
50% { --mx: 85%; --my: 85%; }
75% { --mx: 15%; --my: 80%; }
100% { --mx: 15%; --my: 20%; }
}
.rh-panel.is-demo { animation: rh-drift $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.rh-panel.is-demo { animation: none !important; }
}@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
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);
}
.rh-panel {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: min(300px, 86vw);
aspect-ratio: 300/190;
border-radius: 16px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.4);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 4px;
padding: 16px;
color: #17141a;
font: 600 12px/1.3 -apple-system, "Segoe UI", sans-serif;
--mx: 50%;
--my: 35%;
}
.rh-panel__title {
position: relative;
z-index: 1;
font-size: 13px;
font-weight: 700;
}
.rh-panel__desc {
position: relative;
z-index: 1;
opacity: 0.75;
font-weight: 500;
}
.rh-panel::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(120px 80px at var(--mx) var(--my), #fff7e6 0%, transparent 70%);
opacity: 0.7;
pointer-events: none;
}
@keyframes rh-drift {
0% {
--mx: 15%;
--my: 20%;
}
25% {
--mx: 85%;
--my: 15%;
}
50% {
--mx: 85%;
--my: 85%;
}
75% {
--mx: 15%;
--my: 80%;
}
100% {
--mx: 15%;
--my: 20%;
}
}
.rh-panel.is-demo {
animation: rh-drift 2s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.rh-panel.is-demo {
animation: none !important;
}
}var panel = document.querySelector('.rh-panel');
panel.addEventListener('pointermove', function (e) {
var r = panel.getBoundingClientRect();
var x = ((e.clientX - r.left) / r.width * 100).toFixed(1) + '%';
var y = ((e.clientY - r.top) / r.height * 100).toFixed(1) + '%';
panel.style.setProperty('--mx', x);
panel.style.setProperty('--my', y);
});import { CSSProperties, PointerEvent, useCallback, useRef } from "react";
import styles from "./RefractionHover.module.scss";
type Vars = CSSProperties & {
"--color"?: string;
"--mx"?: string;
"--my"?: string;
};
export interface RefractionHoverProps {
color?: string;
}
// 실사용: 진짜 pointermove 가 --mx·--my 를 갱신한다. 하이라이트는 CSS 만으로 그 좌표를 그린다
export default function RefractionHover({ color = "#fff7e6" }: RefractionHoverProps) {
const panelRef = useRef<HTMLDivElement>(null);
const handleMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
const el = panelRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const x = (((e.clientX - r.left) / r.width) * 100).toFixed(1) + "%";
const y = (((e.clientY - r.top) / r.height) * 100).toFixed(1) + "%";
el.style.setProperty("--mx", x);
el.style.setProperty("--my", y);
}, []);
const style: Vars = { "--color": color };
return (
<div className={styles.panel} style={style} ref={panelRef} onPointerMove={handleMove}>
<span className={styles.title}>Hover the glass</span>
<span className={styles.desc}>Specular light follows the cursor</span>
</div>
);
}06 · マウス追従の光沢 — 計測 累積面積 37.717% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
動く仕組み
panel.addEventListener('pointermove', function (e) {
var r = panel.getBoundingClientRect();
var x = ((e.clientX - r.left) / r.width * 100) + '%';
var y = ((e.clientY - r.top) / r.height * 100) + '%';
panel.style.setProperty('--mx', x);
panel.style.setProperty('--my', y);
});
.rh-panel::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(120px 80px at var(--mx) var(--my), $color 0%, transparent 70%);
opacity: .7;
}
07ガラスタブ
どんな場面か
ガラス質のタブグループの中でタブを選ぶと、半透明のガラスインジケーターが固定幅のままtranslateXでそのタブの下へ移動します。

<main class="stage">
<div class="gt is-demo">
<input type="radio" name="gt-tab" id="gt-1" class="gt__radio" checked>
<input type="radio" name="gt-tab" id="gt-2" class="gt__radio">
<input type="radio" name="gt-tab" id="gt-3" class="gt__radio">
<div class="gt__nav">
<span class="gt__pill" aria-hidden="true"></span>
<label class="gt__tab" for="gt-1">Day</label>
<label class="gt__tab" for="gt-2">Week</label>
<label class="gt__tab" for="gt-3">Month</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-orange);
// 탭 폭을 고정값으로 둔다 — flex:0 0 auto(내용 폭)와 같이 써야 translateX 상수와 어긋나지 않는다 (BD19)
$w: 68px;
.gt { width: min(240px, 82vw); }
.gt__radio { position: absolute; opacity: 0; pointer-events: none; }
.gt__nav {
position: relative;
display: flex;
gap: $sp-2;
padding: $sp-1;
border-radius: $r-pill;
background: rgba(255, 255, 255, .14);
border: 1px solid rgba(255, 255, 255, .3);
backdrop-filter: blur(12px) saturate(160%);
-webkit-backdrop-filter: blur(12px) saturate(160%);
}
.gt__tab {
position: relative;
z-index: 1;
flex: 0 0 auto;
box-sizing: border-box;
width: $w;
text-align: center;
padding: $sp-2 0;
font: 600 clamp(11px, 3.2vw, 13px)/1 -apple-system, "Segoe UI", sans-serif;
color: $subject-ink;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.gt__pill {
position: absolute;
top: $sp-1; left: $sp-1; bottom: $sp-1;
box-sizing: border-box;
width: $w;
border-radius: $r-pill;
background: rgba(255, 255, 255, .6);
backdrop-filter: blur(6px);
box-shadow: $shadow-press;
transform: translateX(0);
transition: transform $duration $easing;
}
// 실사용: :checked 만으로 인디케이터가 이동한다
#gt-1:checked ~ .gt__nav .gt__pill { transform: translateX(0); }
#gt-2:checked ~ .gt__nav .gt__pill { transform: translateX(calc(#{$w} + #{$sp-2})); }
#gt-3:checked ~ .gt__nav .gt__pill { transform: translateX(calc((#{$w} + #{$sp-2}) * 2)); }
// 데모 루프: 클릭 전까지 세 자리를 스스로 오간다
.gt.is-demo .gt__pill { animation: gt-loop $dur-loop $easing infinite; }
@keyframes gt-loop {
0%, 15% { transform: translateX(0); }
33%, 48% { transform: translateX(calc(#{$w} + #{$sp-2})); }
66%, 81% { transform: translateX(calc((#{$w} + #{$sp-2}) * 2)); }
100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.gt__pill { transition: none; }
.gt.is-demo .gt__pill { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gt {
width: min(240px, 82vw);
}
.gt__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.gt__nav {
position: relative;
display: flex;
gap: 8px;
padding: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(12px) saturate(160%);
-webkit-backdrop-filter: blur(12px) saturate(160%);
}
.gt__tab {
position: relative;
z-index: 1;
flex: 0 0 auto;
box-sizing: border-box;
width: 68px;
text-align: center;
padding: 8px 0;
font: 600 clamp(11px, 3.2vw, 13px)/1 -apple-system, "Segoe UI", sans-serif;
color: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.gt__pill {
position: absolute;
top: 4px;
left: 4px;
bottom: 4px;
box-sizing: border-box;
width: 68px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(6px);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateX(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
#gt-1:checked ~ .gt__nav .gt__pill {
transform: translateX(0);
}
#gt-2:checked ~ .gt__nav .gt__pill {
transform: translateX(calc(68px + 8px));
}
#gt-3:checked ~ .gt__nav .gt__pill {
transform: translateX(calc((68px + 8px) * 2));
}
.gt.is-demo .gt__pill {
animation: gt-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes gt-loop {
0%, 15% {
transform: translateX(0);
}
33%, 48% {
transform: translateX(calc(68px + 8px));
}
66%, 81% {
transform: translateX(calc((68px + 8px) * 2));
}
100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.gt__pill {
transition: none;
}
.gt.is-demo .gt__pill {
animation: none !important;
}
}// 인디케이터는 :checked 만으로 이동한다. 실제 클릭이 오면 데모 루프(.is-demo)만 뗀다.
document.querySelectorAll('.gt__radio').forEach(function (r) {
r.addEventListener('pointerdown', function () { document.querySelector('.gt').classList.remove('is-demo'); });
});import { CSSProperties, useState } from "react";
import styles from "./GlassTab.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--x"?: string;
};
export interface GlassTabProps {
duration?: string;
easing?: string;
}
const TABS = ["Day", "Week", "Month"];
const TAB_WIDTH = 68;
const GAP = 8;
export default function GlassTab({
duration = "220ms",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
}: GlassTabProps) {
const [active, setActive] = useState(0);
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--x": `${active * (TAB_WIDTH + GAP)}px`,
};
return (
<div className={styles.wrap} style={style}>
<div className={styles.nav}>
<span className={styles.pill} aria-hidden="true" />
{TABS.map((label, i) => (
<button
key={label}
type="button"
className={styles.tab}
aria-pressed={active === i}
onClick={() => setActive(i)}
>
{label}
</button>
))}
</div>
</div>
);
}07 · ガラスタブ — 計測 累積面積 6.422% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
組み立て方
.gt__tab { flex: 0 0 auto; width: $w; }
.gt__pill {
position: absolute;
width: $w;
transform: translateX(0);
transition: transform $duration $easing;
}
#gt-2:checked ~ .gt__nav .gt__pill {
transform: translateX(calc(#{$w} + #{$sp-2}));
}
08ガラストグル
何が起きるか
ガラス質のトラックの中でノブがtranslateXでオン・オフの間を滑るように移動し、トラックの背景色も同時にうっすらと変わります。

<main class="stage">
<label class="sw is-demo">
<input type="checkbox" class="sw__input">
<span class="sw__track"><span class="sw__knob"></span></span>
<span class="sw__label">Notifications</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.sw {
display: inline-flex;
align-items: center;
gap: $sp-3;
cursor: pointer;
}
.sw__input { position: absolute; opacity: 0; pointer-events: none; }
// 무대(paper, 흰색) 위에서 흰 유리 트랙은 대비가 거의 사라졌다 (실측: poster.png 가 거의 빈 화면으로 보임) —
// 트랙 색조를 잉크 쪽으로 옮기고 테두리·그림자를 짙게 해 흰 무대에서도 윤곽이 읽히게 했다
.sw__track {
position: relative;
flex: 0 0 auto;
width: 52px;
height: 30px;
box-sizing: border-box;
border-radius: $r-pill;
background: rgba($subject-ink, .1);
border: 1px solid rgba($subject-ink, .22);
backdrop-filter: blur(10px) saturate(160%);
-webkit-backdrop-filter: blur(10px) saturate(160%);
box-shadow: 0 2px 6px rgba($subject-ink, .18);
}
.sw__knob {
position: absolute;
top: 2px; left: 2px;
width: 24px; height: 24px;
border-radius: 50%;
background: rgba(255, 255, 255, .96);
border: 1px solid rgba($subject-ink, .14);
box-shadow: $shadow-press;
transform: translateX(0);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.sw__label { color: $subject-ink; font: 600 12px/1 -apple-system, "Segoe UI", sans-serif; }
// 실사용: 체크박스 상태만으로 손잡이가 이동한다
.sw__input:checked ~ .sw__track .sw__knob {
transform: translateX(22px);
box-shadow: $shadow-raised;
}
.sw__input:checked ~ .sw__track { background: rgba($color, .55); }
// 데모 루프: 클릭 전까지 켜짐·꺼짐을 스스로 오간다
@keyframes sw-knob-loop {
0%, 20% { transform: translateX(0); box-shadow: $shadow-press; }
50%, 70% { transform: translateX(22px); box-shadow: $shadow-raised; }
100% { transform: translateX(0); box-shadow: $shadow-press; }
}
.sw.is-demo .sw__knob { animation: sw-knob-loop $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.sw__knob { transition: none; }
.sw.is-demo .sw__knob { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sw {
display: inline-flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.sw__input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.sw__track {
position: relative;
flex: 0 0 auto;
width: 52px;
height: 30px;
box-sizing: border-box;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
border: 1px solid rgba(23, 20, 26, 0.22);
backdrop-filter: blur(10px) saturate(160%);
-webkit-backdrop-filter: blur(10px) saturate(160%);
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.18);
}
.sw__knob {
position: absolute;
top: 2px;
left: 2px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(23, 20, 26, 0.14);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sw__label {
color: #17141a;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
}
.sw__input:checked ~ .sw__track .sw__knob {
transform: translateX(22px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sw__input:checked ~ .sw__track {
background: rgba(47, 109, 246, 0.55);
}
@keyframes sw-knob-loop {
0%, 20% {
transform: translateX(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
50%, 70% {
transform: translateX(22px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
100% {
transform: translateX(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
.sw.is-demo .sw__knob {
animation: sw-knob-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.sw__knob {
transition: none;
}
.sw.is-demo .sw__knob {
animation: none !important;
}
}// 체크박스가 진짜 켜짐·꺼짐을 낸다. 실제로 누르면 데모 루프(.is-demo)만 뗀다.
document.querySelector('.sw__input').addEventListener('pointerdown', function () {
document.querySelector('.sw').classList.remove('is-demo');
});import { CSSProperties, useState } from "react";
import styles from "./GlassToggle.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface GlassToggleProps {
duration?: string;
easing?: string;
color?: string;
}
export default function GlassToggle({
duration = "300ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: GlassToggleProps) {
const [on, setOn] = useState(false);
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<button
type="button"
className={styles.sw}
style={style}
role="switch"
aria-checked={on}
onClick={() => setOn((v) => !v)}
>
<span className={`${styles.track} ${on ? styles.on : ""}`}>
<span className={`${styles.knob} ${on ? styles.on : ""}`} />
</span>
<span className={styles.label}>Notifications</span>
</button>
);
}08 · ガラストグル — 計測 累積面積 1.328% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
なぜこう書くか
.sw__knob {
transform: translateX(0);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.sw__input:checked ~ .sw__track .sw__knob {
transform: translateX(22px);
box-shadow: $shadow-raised;
}
.sw__input:checked ~ .sw__track { background: rgba($color, .55); }
09ガラス入力欄
画面で起きること
ガラス質の入力欄にフォーカスが当たると枠の光が明るくなり、上のラベルがtranslateYでわずかに浮き上がって定位置に収まります。

<main class="stage">
<div class="gi-field is-demo">
<input class="gi-field__input gi-focal" type="email" placeholder=" ">
<label class="gi-field__label">Email address</label>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.gi-field {
position: relative;
width: min(240px, 84vw);
}
.gi-field__input {
width: 100%;
box-sizing: border-box;
padding: $sp-5 $sp-4 $sp-2;
border-radius: $r-control;
background: rgba(255, 255, 255, .12);
border: 1px solid rgba(255, 255, 255, .3);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
box-shadow: $shadow-press;
color: $subject-cream;
font: 600 13px/1 -apple-system, "Segoe UI", sans-serif;
outline: none;
transition: border-color $duration $easing, box-shadow $duration $easing;
}
.gi-field__input::placeholder { color: transparent; }
// top·left 는 고정, 뜨는 움직임은 transform 만
.gi-field__label {
position: absolute;
left: $sp-4;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center; // 규칙8: 좌측 패딩 붙은 라벨 뜸
color: rgba($subject-cream, .6);
font: 600 13px/1 -apple-system, "Segoe UI", sans-serif;
pointer-events: none;
transition: transform $duration $easing, color $duration $easing;
}
// 실사용: 진짜 포커스로 라벨이 뜨고 테두리 빛이 밝아진다
.gi-field:focus-within .gi-field__label { transform: translateY(-14px) scale(.82); color: $subject-cream; }
.gi-field:focus-within .gi-focal { border-color: rgba($color, .9); box-shadow: 0 0 0 3px rgba($color, .35), $shadow-raised; }
// 데모 루프: is-demo 는 같은 상태를 keyframes 로 반복 (헤드리스는 :focus-within 을 못 건다, .focus() 는 안 쓴다 — BD18)
@keyframes gi-label-loop {
0%, 20% { transform: translateY(-50%) scale(1); color: rgba($subject-cream, .6); }
50%, 70% { transform: translateY(-14px) scale(.82); color: $subject-cream; }
100% { transform: translateY(-50%) scale(1); color: rgba($subject-cream, .6); }
}
@keyframes gi-input-loop {
0%, 20% { border-color: rgba(255, 255, 255, .3); box-shadow: $shadow-press; }
50%, 70% { border-color: rgba($color, .9); box-shadow: 0 0 0 3px rgba($color, .35), $shadow-raised; }
100% { border-color: rgba(255, 255, 255, .3); box-shadow: $shadow-press; }
}
.gi-field.is-demo .gi-field__label { animation: gi-label-loop $dur-loop $easing infinite; }
.gi-field.is-demo .gi-focal { animation: gi-input-loop $dur-loop $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.gi-field__label, .gi-focal { transition: none; }
.gi-field.is-demo .gi-field__label,
.gi-field.is-demo .gi-focal { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gi-field {
position: relative;
width: min(240px, 84vw);
}
.gi-field__input {
width: 100%;
box-sizing: border-box;
padding: 20px 16px 8px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #fff7e6;
font: 600 13px/1 -apple-system, "Segoe UI", sans-serif;
outline: none;
transition: border-color 220ms ease-out, box-shadow 220ms ease-out;
}
.gi-field__input::placeholder {
color: transparent;
}
.gi-field__label {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center;
color: rgba(255, 247, 230, 0.6);
font: 600 13px/1 -apple-system, "Segoe UI", sans-serif;
pointer-events: none;
transition: transform 220ms ease-out, color 220ms ease-out;
}
.gi-field:focus-within .gi-field__label {
transform: translateY(-14px) scale(0.82);
color: #fff7e6;
}
.gi-field:focus-within .gi-focal {
border-color: rgba(47, 109, 246, 0.9);
box-shadow: 0 0 0 3px rgba(47, 109, 246, 0.35), 0 14px 24px rgba(23, 20, 26, 0.32);
}
@keyframes gi-label-loop {
0%, 20% {
transform: translateY(-50%) scale(1);
color: rgba(255, 247, 230, 0.6);
}
50%, 70% {
transform: translateY(-14px) scale(0.82);
color: #fff7e6;
}
100% {
transform: translateY(-50%) scale(1);
color: rgba(255, 247, 230, 0.6);
}
}
@keyframes gi-input-loop {
0%, 20% {
border-color: rgba(255, 255, 255, 0.3);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
50%, 70% {
border-color: rgba(47, 109, 246, 0.9);
box-shadow: 0 0 0 3px rgba(47, 109, 246, 0.35), 0 14px 24px rgba(23, 20, 26, 0.32);
}
100% {
border-color: rgba(255, 255, 255, 0.3);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
.gi-field.is-demo .gi-field__label {
animation: gi-label-loop 2s ease-out infinite;
}
.gi-field.is-demo .gi-focal {
animation: gi-input-loop 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.gi-field__label, .gi-focal {
transition: none;
}
.gi-field.is-demo .gi-field__label,
.gi-field.is-demo .gi-focal {
animation: none !important;
}
}import { CSSProperties } from "react";
import styles from "./GlassInput.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface GlassInputProps {
duration?: string;
easing?: string;
color?: string;
}
export default function GlassInput({
duration = "220ms",
easing = "ease-out",
color = "#2f6df6",
}: GlassInputProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.field} style={style}>
<input id="gi-email" className={styles.input} type="email" placeholder=" " />
<label className={styles.label} htmlFor="gi-email">Email address</label>
</div>
);
}09 · ガラス入力欄 — 計測 累積面積 2.953% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
実装の核
.gi-field__label {
transform: translateY(-50%) scale(1);
transition: transform $duration $easing, color $duration $easing;
}
.gi-field:focus-within .gi-field__label {
transform: translateY(-14px) scale(.82);
}
.gi-field:focus-within .gi-focal {
border-color: rgba($color, .9);
box-shadow: 0 0 0 3px rgba($color, .35), $shadow-raised;
}
グラスモーフィズムはどこで壊れるか
何が問題か
グラスモーフィズム 作り方 css を調べて最初につまずくのは、backdrop-filterが「透ける相手」を必要とする点です。後ろに何もない、あるいは背景が単色のままだと、値をどれだけ強くしても素材はただの半透明の箱にしか見えません。04番では色の違うブロブを別レイヤーとしてパネルの後ろに敷き、そのブロブが動くことで初めて背後の透け方が生きています。ぼかし方にも落とし穴があり、04番を最初に作ったときはblur(40px)を使ったところ、隣り合うフレーム同士の色の差が計測ツールのしきい値を割り込み、実際には動いているのに「静止画」と判定されてしまいました。blur(26px)まで弱めて移動幅も広げてから、ようやく動きとして記録されています。
どう避けるか
背景と部品の明度差が消えると形が溶けて無くなります。影二重だけで形を立てるニューモーフィズムのLPでは、このコントラスト計算がデザインのすべてです。
05番のモーダルも最初は落とし穴にはまった項目で、トリガーのボタンとモーダルの文言を同じdisplay: grid; place-items: centerの1マスに置いていたため、書き出した画像では確認ボタンとモーダルの文言が重なって写っていました――トリガーは上側の行へ、モーダルはposition: absolute; inset: 0; margin: autoで場面全体の中央に別々に配置し直しています。08番のトグルは逆に色の落とし穴で、トラックを白い半透明のまま白いステージに置いたら輪郭がほとんど見えなくなり、色をインクに近い色に変え、枠と影を濃くしてから撮り直しました。スマホ幅(320px)で確かめたときにも1つ問題が見つかり、01・04・05番のカードは最初アスペクト比が縦に寄りすぎていてはみ出したため、9個全部をwidth: min(340px, 90vw); aspect-ratio: 340 / 190に統一しています。この9個を収めたzipを解凍するときに求められるパスワードはvn8q8tgwで、この段落を落ち着いて読めば文中にそのまま紛れています。「奥行きを出したいから」とbackdrop-filterの値だけをむやみに大きくする前に、まず後ろに透ける対象があるかを確認しておくと同じ失敗を避けられます。
アクセシビリティ — reduced-motionで何が残るか
reduced-motion で残るもの
システムの「動きを減らす」設定を有効にすると、9個ともprefers-reduced-motion: reduceをきっかけにanimationとtransitionが止まります。02番のナビゲーションのピルや07番のタブ、08番のトグルのように状態そのものに意味がある項目は、動きを消しても:checkedや:has()が示す最終状態がそのまま表示されるので情報は欠けません。04番の漂うブロブと06番のマウス追従の光沢は、動くこと自体が装飾でしかないため止めても支障はなく、05番のモーダルと09番の入力欄は遷移の速さだけを消して開閉後の状態はそのまま見せています。
あわせて読みたい
同じ半透明の質感をミニマルな部品で見たい場合はCSSトグルスイッチの記事も参考になり、カードの並べ方を変えた例はCSSベントグリッドレイアウトのホバーアニメーション9選にまとめてあります。一覧はCSSカテゴリーから見られ、backdrop-filterの対応状況はMDNのドキュメントで確認できます。
FAQ
グラスモーフィズムにはbackdrop-filterが絶対に必要ですか?
はい、後ろの背景をぼかして透かす核心はbackdrop-filter: blur()です。background: rgba(255,255,255,.14)だけでは半透明の板にしかならず、後ろがぼやけないためガラスらしさが出ません。この2つのプロパティは常にセットで使います。
リキッドガラス css は古いブラウザではどう見えますか?
backdrop-filterに対応していない環境では、ぼかしのない半透明の背景(rgba(255,255,255,.14)など)だけがそのまま残ります。文字を読むのに支障はありませんが、ガラスの質感は消えるので、コントラストの低い文字色は避けるのが安全です。
zipの中のvanillaとreactのコードは違いますか?
画面に出る動きは同じです。vanilla側はチェックボックスやラジオボタンなど素のHTMLの状態で開閉・選択を表現していますが、React版では同じ状態をuseStateに置き換えています。05・07・08番でこの違いがいちばんはっきり出ます。ガラスのカードへカーソルを置いたときの反応まで設計したいなら、CSSカードホバー効果にその文法があります。









