9 CSS Glassmorphism Effects to Copy-Paste
CSS glassmorphism is the frosted-glass look: content on a panel that blurs whatever sits behind it, the way a shower door blurs the room past it.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Frosted card
- 02 Glass navbar
- 03 Liquid glass button refraction
- 04 Moving glass blob background
- 05 Glass modal
- 06 Refraction follows cursor
- 07 Glass tab
- 08 Glass toggle
- 09 Glass input
These nine aren't ranked by how flashy they look — they follow how much a visitor has to do before the glass reacts. First come two surfaces that already read as glass just sitting on the page, nothing pressed (01, 02), then two set off by resting a pointer or by nothing at all but time passing (03's one-off light sweep, 04's looping blobs), then the two that need an actual click or a moving cursor (05's modal, 06's cursor-tracked shine), and last the three form controls that respond to picking, flipping, or typing into something (07–09). Filling the glass with photographs to page through makes the CSS image reveal gallery its companion, and throwing a party for a finished flow is what the confetti celebration effects follow with.
01Frosted card
Where a css glassmorphism card fits
A feature card sits on a frosted, translucent surface over two soft color blobs, so the blurred color showing through makes the card read like a pane of glass rather than a flat box.

<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 · Frosted card — Measured area 4.222% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
Resting the pointer on it scales the card up slightly and pushes its shadow deeper.
.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;
}
02Glass navbar
When to use it
Inside a frosted navigation bar, a blurred glass pill sits behind whichever link the pointer is resting on and slides over to it via 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 · Glass navbar — Measured area 6.783% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
Every link keeps the same fixed width, so the pill always lands exactly under the link no matter how long its label runs.
.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}));
}
03Liquid glass button refraction
What you see
A narrow band of light sits across a glass button's surface, and hovering slides it once from edge to edge via translateX, skewed on an angle so it reads as a refraction glinting through liquid glass rather than a straight wipe.

<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 · Liquid glass button refraction — Measured area 3.422% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
.lb-btn::before {
content: "";
position: absolute;
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);
}
04Moving glass blob background
Where it fits
Two softly colored blobs drift behind a glass panel via transform, looping on their own with no hover needed, and the panel's backdrop-filter shows that slow drift as color shifting gently through the glass rather than a sharp shape moving.

<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 · Moving glass blob background — Measured area 41.944% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
.bb-blob {
border-radius: 50%;
filter: blur(26px);
opacity: .88;
}
.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); }
}
05Glass modal
What moves
Pressing the trigger button opens a glass modal panel that grows in from small via opacity and scale, while the page behind it stays visible but blurred.

<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 · Glass modal — Measured area 14.35% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
A hidden checkbox drives the whole thing — no JavaScript opens or closes it.
.gm-modal {
opacity: 0;
transform: scale(.85);
transition: opacity $duration $easing, transform $duration $easing;
}
.gm-check:checked ~ .gm-modal {
opacity: 1;
transform: scale(1);
}
06Refraction follows the cursor
The moment it fires
Moving the pointer across a large glass panel drags an elliptical highlight along with it in real time, like light sliding over the surface of glass.

<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 · Refraction follows cursor — Measured area 37.717% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
A handful of lines of JavaScript write the pointer's position into two custom properties that a radial-gradient reads directly.
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 {
background: radial-gradient(120px 80px at var(--mx) var(--my), $color 0%, transparent 70%);
}
07Glass tab
What the user notices
Picking a tab in a glass tab group slides a translucent indicator under it via translateX, driven purely by which radio input is :checked.

<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 · Glass tab — Measured area 6.422% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
Every tab is locked to the same fixed width, so the indicator's travel distance never has to guess at how long a label is.
.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}));
}
08Glass toggle
Where this shows up
Flipping a glass switch slides its knob from one side of the track to the other via translateX, and the track's own tint shifts from a faint ink tone to a stronger color to mark the change.

<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 · Glass toggle — Measured area 1.328% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
A hidden checkbox's :checked state drives both moves at once.
.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); }
09Glass input
What happens on screen
Focusing a glass input field brightens its border with a soft glow while the label above the text floats up and shrinks into place via translateY and scale, clearing room for whatever gets typed.

<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 · Glass input — Measured area 2.953% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
Nothing here calls .focus() from JavaScript — :focus-within alone drives the whole move.
.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;
}
Where does glassmorphism break?
What goes wrong
The bug most likely to bite anyone copying these nine — or pasting the output of a css glassmorphism generator — showed up in item 04's drifting blobs, and it wasn't a CSS mistake — the blur was hiding the motion from anyone measuring it, this site's own render check included. The first pass used filter: blur(40px), which looks nicer at a glance since the blobs melt into softer edges, but a strong blur also averages neighboring pixels together, so two consecutive frames of a slow drift end up reading as nearly identical even while the blob keeps moving underneath. That render measured a per-frame pixel difference of 11 to 12 against this site's motion floor of 12 — just under the line that separates "moving" from "static" — so item 04 came back with zero moved frames despite covering close to 42% of the panel in blurred color. Dropping the blur to 26px and widening the blob's own travel distance from 60px to 110px was the entire fix: same shapes, same drift, just enough per-frame pixel change left over for the measurement to catch it. Item 05's modal hit a smaller, more visible version of the same "looks fine, isn't" problem — the trigger button and the modal both sat centered with display: grid; place-items: center inside one shared scene, so once the modal opened, its "Delete this file?" text rendered stacked directly on top of the trigger's own label, a collision no console warning would ever catch, only a screenshot actually opened and looked at.
How to avoid it
When the lightness gap between panel and page disappears the shape melts — in the neumorphism landing kit, which builds form from two shadows alone, that contrast arithmetic is the entire design.
Moving the trigger into its own row and pinning the modal with position: absolute; inset: 0; margin: auto inside the scene kept the two apart for good. One more thing worth knowing before copying any of this: backdrop-filter only blurs real content sitting behind the panel, so a card floating over a single flat color — no blobs, no photo, nothing textured back there — just reads as a plain translucent box no matter how high the blur value goes. That's exactly why every one of these nine keeps a patterned stage, a photo, or a drifting blob layer of its own sitting behind the glass rather than trusting a flat backdrop to carry the effect — a lone backdrop-filter line with nothing worth blurring behind it is the single most common reason a "glassmorphism" card someone built ends up looking like plain frosted plastic instead. Pair the blur with something behind it worth blurring, and the rest of the effect mostly takes care of itself. As for the zip below, it needs a password, and instead of boxing it off, it's sitting mid-sentence like any other word right here: vn8q8tgw — copy exactly that and nothing else.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Subtler frost | Item 01's backdrop-filter: blur(16px) cut to blur(8px) |
Reads closer to frosted plastic than a lens — still legible, less like glass |
| Wider refraction | Item 03's highlight band widened from width: 40% to width: 70% |
The sweep reads as a soft wash across the whole button instead of a single glint |
| Calmer drift | Item 04's travel distance pulled back from translate(110px, 70px) to translate(50px, 30px) |
The blobs still clear this site's motion floor but barely shift the light coming through the panel |
Accessibility
What reduced motion leaves
All nine drop their motion under prefers-reduced-motion: reduce, and in every case the underlying state still comes through — nothing here depends on watching a transition to know what's true. Item 01's card, item 07's tab pill, and item 08's toggle knob simply snap straight to their end position instead of easing there; item 05's modal still opens and closes on the same checkbox, just instantly; item 09's label still jumps up into place the moment a field is focused. Two items lose only decoration that was never carrying information: item 03's light sweep is forced to stay parked at its starting position even on a real hover, and item 04's blobs freeze in place entirely, leaving the glass panel in front of them just as readable. Item 06 is the one exception worth flagging — its always-on demo loop turns off, but the real pointermove tracking underneath keeps working exactly as before, since reduced motion was never meant to disable something a visitor is actively steering with their own hand. One thing prefers-reduced-motion doesn't touch at all: backdrop-filter itself is one of the more expensive properties a browser paints, motion or none, so a real page stacking many of these panels at once is worth testing on a slower device regardless of anyone's motion setting.
Related reading
Item 08's knob shares its :checked trigger with the switches covered end to end in 9 CSS Toggle Switches, and the wider family of :checked-driven, JavaScript-free patterns that items 05, 07, and 08 all draw from gets its own write-up in 9 CSS Checkbox Radio Animations. Browser support for backdrop-filter itself is worth checking on MDN before shipping any of this to a page with older-browser traffic.
FAQ
Do I need backdrop-filter for glassmorphism, or is a translucent background enough?
backdrop-filter: blur() is doing the actual work — it's what blurs whatever sits behind the panel. A background: rgba(255,255,255,.14) on its own just makes the panel see-through without blurring anything, which reads as a plain tinted box rather than glass. Item 01 pairs the two together, and that pairing is what every one of these nine effects relies on.
What happens to a liquid glass effect css panel in a browser without backdrop-filter?
The panel falls back to its plain translucent background with no blur at all — still fully readable, just without the frosted look. Item 08's toggle is the one to check first if that fallback worries you: it had to swap a white glass track for an ink-toned one because a light glass surface on this site's white stage nearly vanished even with the blur working, so text and controls should never lean on the blur alone for contrast.
Is the React version in the zip built differently from the plain CSS/HTML one?
Nothing about what moves, or when, changes. Items 05, 07, and 08 swap their hidden checkbox or radio inputs for React useState, since a component tree doesn't have a DOM sibling to read :checked off of the way plain HTML does. Item 06's pointermove listener and the --mx/--my custom properties it writes carry over unchanged — that part never depended on checkboxes or radios in the first place. Designing what happens when a cursor rests on the glass continues in the CSS card hover effects.









