designpaca/packages/skill/references/svg-glass.md
Yun Chan 88c11682b1 feat(skill): route fixes, delivery report, composition diversity, platform refresh
- route table: extension route for products with an existing visual
  language, feedback row, derived state tokens stay local
- completion: blocking 0 plus reported unverified; hard-gate surface is
  the touched scope; delivery report starts with what and how to open
- design.md created at step 0 with a skeleton, fingerprint line and
  change log; product-surface rubric reading; direction delegation
  states the chosen card and reason
- layout: hero families, nav forms, footer forms; art-direction page
  convergence 6 axes; genre sections; image treatments; placeholder
  conventions; multi-screen order; Tailwind theme mapping
- platform: scroll-driven examples, anchor positioning, canvas OT,
  three/R3F pins, shadcn style prefixes, Safari 26 notes, subgrid
- design-gate: four result states, exit codes, cwd module resolution,
  --url/--out-dir/--strict, oklch contrast, dead selector word match
- Korean font budget split from the Latin baseline
2026-10-06 16:55:38 +09:00

15 KiB
Raw Permalink Blame History

svg-glass — 유리 재질: 헤어라인·굴절·머티리얼 위계

svg-filters.md에서 분리한 하위 문서다. 4-2에서 재질을 유리로 정한 뒤에만 연다. 재질을 고르는 표(§1), 공통 뼈대, 성능(§6), 접근성·폴백(§7), 채택 체크리스트(§8)는 svg-filters.md가 정본이고 여기서 다시 정의하지 않는다. 깊이·그림자 단계는 elevation.md를 따른다.

이 문서를 읽는 법

상황 읽을 곳
유리 패널 가장자리에 1px 광을 두른다 §1
테두리 광을 회전시킨다 §1-1
배경이 실제로 휘는 굴절 유리(리퀴드 글래스)를 만든다 §2 — 코드를 짜기 전에 §2-3 함정부터
굴절이 안 보인다 §2-3 → §2-4 진단법
유리 위 텍스트 가독성, 반투명 겹침, 스티키 헤더, 진입 전환, reduced transparency §3
감사 직전 svg-filters.md §8 + 아래 §4

1. 유리 테두리(헤어라인)를 그릴 때

1px 테두리에 광을 흘리는 흔한 방법은 그라디언트를 깔고 마스크로 테두리만 남기는 것이다.

.glass::after {
  content: ""; position: absolute; inset: 0; padding: 1px;
  border-radius: inherit;
  background: var(--glass-rim);
  mask: linear-gradient(black 0 0) content-box, linear-gradient(black 0 0);
  mask-composite: exclude;   /* -webkit-mask-composite: xor */
}

conic-gradient 를 쓰지 마라. 중심에서 각도로 돌기 때문에, 납작한 형태 (내비게이션 바처럼 1180×54)에서는 각도가 급변해 변마다 광이 끊긴다. 긴 변에서는 색이 거의 안 변하고 짧은 변에서 한꺼번에 바뀐다.

/* 네 변이 같은 그라디언트를 공유한다 — 테두리를 따라 이어진다 */
--glass-rim: linear-gradient(100deg,
  rgb(255 255 255 / 0.34) 0%,
  rgb(255 255 255 / 0.10) 26%,
  rgb(255 255 255 / 0.06) 58%,
  rgb(255 255 255 / 0.28) 100%);

정사각형에 가까운 카드라면 conic 도 자연스럽다. 비율을 보고 골라라.

글로우를 더한다면 테두리에서 얕게 번지는 정도로 충분하다 — 0 0 22px -8px 처럼 음수 spread 를 써서 형태 밖으로 새지 않게 한다. 밝은 배경에서는 흰빛 대신 그림자로 뒤집어야 같은 인상이 난다.

1-1. 테두리 광을 회전시키려면

conic-gradient(from var(--angle), …) 를 @property 로 애니메이션하는 방법이 흔히 보이는데, 그건 매 프레임 배경을 다시 칠한다. 컴포지터에서 못 돌고 메인 스레드가 페인트한다. 저비용 기본 선택에서 벗어나는 사례다. 목적과 성능 측정, 감소 모션 경로를 프로젝트 계약에 기록한다.

정사각형 원뿔 레이어를 마스크 안에서 회전시켜라. 움직이는 것은 rotate 뿐이다.

.rim {                      /* 테두리만 남기는 마스크 */
  position: absolute; inset: 0; border-radius: inherit;
  overflow: hidden; padding: 1px;
  mask: linear-gradient(black 0 0) content-box, linear-gradient(black 0 0);
  mask-composite: exclude;
}
.rim::before {              /* 실제로 도는 것 */
  content: ""; position: absolute; top: 50%; left: 50%;
  width: 200%; aspect-ratio: 1;      /* 정사각형이라 회전해도 밝기가 균일하다 */
  translate: -50% -50%;
  background: conic-gradient(from 0deg, transparent 0 58%, var(--glow) 74%, transparent 86%);
  animation: rim-spin 11s linear infinite;
}
@keyframes rim-spin { to { rotate: 360deg; } }

두 가지를 지켜라.

  • aspect-ratio: 1 — 정사각형이어야 회전 중 밝기가 일정하다. 납작하면 변마다 달라진다
  • width: 200% — 요소보다 크게 잡아야 납작한 형태에서도 모서리까지 닿는다

prefers-reduced-motion 에서는 애니메이션만 빼면 된다. 정지 상태의 광은 그대로 남는다.

2. 리퀴드 글래스 — backdrop-filter: url() 은 살아 있다

정정부터. 이 문서는 한때 "Chrome 은 CSS.supports 로 true 를 주면서 렌더에서 backdrop-filter 선언 전체를 버린다" 고 적고 있었다. 틀렸다. Chrome 151 에서 실측하면 computed 값이 none 이 아니라 url("#id") 로 유지되고, 배경이 실제로 굴절된다. 우리가 겪은 실패는 다른 원인이었다(아래 §2-3).

2-1. 구조

<svg style="position:absolute;width:0;height:0">
  <filter id="lg" color-interpolation-filters="sRGB"
          x="0" y="0" width="100%" height="100%">
    <!-- 변위 지도. feTurbulence 가 아니라 사전계산 이미지다 -->
    <feImage href="data:image/png;base64,…" x="0" y="0" width="240" height="160" result="map"/>
    <feDisplacementMap in="SourceGraphic" in2="map" scale="34"
                       xChannelSelector="R" yChannelSelector="G"/>
  </filter>
</svg>
.glass { backdrop-filter: url(#lg); }

2-2. 변위 지도를 어떻게 만드나

노이즈가 아니다. 유리는 무작위로 휘지 않는다 — 가장자리에서 경계 법선 방향으로 휜다.

  • R 채널 = X 변위, G 채널 = Y 변위, 128 이 중립(변위 없음)
  • 값 인코딩: r = 128 + x * 127, g = 128 + y * 127
  • 가장자리에서 거리 d 를 구해 그 함수로 변위 크기를 정한다
  • 감쇠 곡선은 Squircle y = ⁴√(1 - (1-x)⁴) 이 원보다 자연스럽다(Apple 이 쓰는 곡선)

캔버스로 즉석 생성해 toDataURL() 로 넘기면 이미지 파일이 필요 없다. 실측: 240×160 맵이 2.7KB data URI.

2-3. 함정 — 여기서 실패한다

  1. feImage 의 width/height 를 요소 크기와 맞춰라. backdrop-filter 는 요소 크기에 맞춰 필터 이미지를 자동 조정하지 않는다. 크기가 어긋나면 굴절이 엉뚱한 곳에 걸리거나 아예 안 보인다. 요소가 리사이즈되면 맵을 다시 만들어야 한다
  2. Chrome 전용이다. Safari·Firefox 는 backdrop-filter 에 SVG 필터를 받지 않는다. @supports 로 감싸고 blur() 폴백을 둬라
  3. 모양·크기를 애니메이션하지 마라. 변위 맵 전체가 다시 계산된다. <feDisplacementMap scale> 만 애니메이션해라 — 그건 재계산이 없다
  4. color-interpolation-filters="sRGB" 를 빼면 색이 어둡게 뜬다
.glass { backdrop-filter: blur(18px) saturate(150%); }   /* 기본 */
@supports (backdrop-filter: url(#lg)) {
  .glass { backdrop-filter: url(#lg); }                   /* 굴절 */
}

표준화가 진행 중이다(w3c/svgwg#1142). 지금은 Chrome 한정 기법으로 다루고, 굴절이 없어도 성립하는 화면을 먼저 만든 뒤에 얹어라.

2-4. 실패 기록 — feTurbulence 를 변위 지도로 쓰면 안 된다

위에 "노이즈가 아니다" 라고 적어놓고, 나중에 이 문서를 안 보고 이렇게 썼다.

<feTurbulence baseFrequency="0.02" numOctaves="2" result="n"/>
<feGaussianBlur in="n" stdDeviation="2.5" result="soft"/>
<feDisplacementMap in="SourceGraphic" in2="soft" scale="42" .../>

backdrop-filter 에 걸고 재보니 필터 전후 평균 채널차가 1.66 이었다. scale 을 42 에서 120 으로 세 배 올려도 2.96 — 사실상 아무 일도 안 일어난다. 그때 내린 결론은 "Chrome 이 backdrop-filter 체인에서 displacement 를 못 쓴다" 였고, 그것도 틀렸다. 원인은 두 가지 다 이 문서에 이미 적혀 있었다.

  1. 블러한 난류는 중립값으로 수렴한다. 변위 지도에서 128 은 "움직이지 마라" 다. fractalNoise 의 평균이 그 근처인데 거기에 블러까지 걸면 지도 전체가 128 이 된다. scale 을 아무리 키워도 0 을 곱하는 것이라 변하지 않는다.
  2. feImage 의 크기를 요소에 맞추지 않았다. 자동 조정되지 않는다.

교훈은 필터가 아니라 작업 방식이다 — 이미 기록해 둔 함정에 다시 빠지는 것은 문서가 없어서가 아니라 쓰기 전에 읽지 않아서다. 4단계에서 필터를 쓰기로 했으면 레시피를 짜기 전에 이 문서의 §2-3 함정을 먼저 편다.

진단법: 굴절이 안 보일 때 scale 부터 올리지 마라. 지도를 화면에 직접 그려 128 이 아닌 픽셀이 실제로 있는지 먼저 봐라. 없으면 지도가 문제다.

2-5. 지도를 SVG 로 그릴 때 — R 과 G 를 한 장에 못 담는다

linearGradient 하나로는 R 과 G 를 각각 다르게 변화시킬 수 없다. 겹쳐 그리면 뒤엣것이 앞엣것을 덮고, 블렌드 모드로는 채널이 섞인다. 지도를 두 장 놓고 displacement 를 두 번 건다. 쓰지 않는 축에는 128 로 고정된 B 채널을 물려 이동량을 0 으로 만든다.

<filter id="lens" color-interpolation-filters="sRGB" x="0" y="0" width="100%" height="100%">
  <feImage href={MAP_X} x="0" y="0" width="216" height="72" result="mx"/>
  <feDisplacementMap in="SourceGraphic" in2="mx" scale="30"
                     xChannelSelector="R" yChannelSelector="B" result="dx"/>
  <feImage href={MAP_Y} x="0" y="0" width="216" height="72" result="my"/>
  <feDisplacementMap in="dx" in2="my" scale="30"
                     xChannelSelector="B" yChannelSelector="G"/>
</filter>

지도 한 장은 가장자리에서만 중립을 벗어나는 그라디언트다. data URI 안에서 # 은 URI 를 끊으므로 색은 rgb() 로 적는다.

`<svg xmlns="http://www.w3.org/2000/svg" width="216" height="72">` +
  `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="0">` +
    `<stop offset="0"    stop-color="rgb(255,128,128)"/>` +   // 왼쪽 끝: +X 최대
    `<stop offset="0.30" stop-color="rgb(128,128,128)"/>` +   // 가운데: 중립
    `<stop offset="0.70" stop-color="rgb(128,128,128)"/>` +
    `<stop offset="1"    stop-color="rgb(0,128,128)"/>` +     // 오른쪽 끝: -X 최대
  `</linearGradient></defs>` +
  `<rect width="216" height="72" fill="url(#g)"/></svg>`

확인 방법: 배경에 격자를 깔아라. 사진만 놓으면 무엇이 어떻게 밀렸는지 알 수 없다. 제대로 걸리면 패널 가장자리에서 격자 셀이 눌린다 — 그것이 렌즈의 지문이다. 실측: 필터 전후 평균 채널차 1.66(난류 지도) → 9.29(그린 지도), 최대 126.

backdrop-filter 앞의 blur() 는 약하게 둬라. 세게 걸면 굴절이 뭉개져서 지도를 고쳐도 차이가 안 보인다.

3. 머티리얼 위계와 가독성 (vibrancy)

언제: 브리프나 골라 둔 방향이 유리·Apple풍 질감을 채택했을 때 연다. swiss-minimal처럼 평면 표면을 기본으로 하는 방향에서는 기본 비채택이다 — 리믹스 카드나 브리프에서 재질 축을 유리에 명시적으로 배정했을 때만 쓴다(svg-filters.md §1). 아래 규칙은 "모든 nav·toolbar·sheet를 backdrop-filter로 만들어라"는 지시가 아니다 — 유리를 쓰기로 한 뒤의 마감 규칙이다[SKILL-APPLE-DESIGN].

블러 위 텍스트 가독성(vibrancy): 반투명·블러 표면 위에 플랫 그레이 텍스트를 얹지 마라. 배경이 바뀌어도 읽히도록 대비를 올리고, 자간을 미세하게 늘리고, 굵기를 한 단계 올린다. 색은 솔리드 레이어에 두고 반투명 전경에 두지 않는다 — 반투명 위에서는 배경에 따라 채도가 흔들린다.

반투명 표면끼리 겹치지 않기: 밝은 반투명 표면을 다른 반투명 표면 위에 쌓지 마라. 겹칠수록 대비가 무너져 가독성이 붕괴한다. 겹쳐야 하면 아래쪽을 솔리드에 가깝게 불투명도를 올려라.

무거운 재질은 구조, 가벼운 재질은 상호작용: 더 어둡고 무거운 유리(강한 블러 + 깊은 그림자)는 사이드바 같은 구조적 영역을 분리하는 데 쓰고, 더 밝고 가벼운 유리는 버튼 같은 인터랙티브 요소로 주의를 끄는 데 쓴다. 큰 표면일수록 더 두껍게(강한 블러 + 깊은 그림자) 읽혀야 한다 — 작은 칩에 큰 표면급 블러를 걸지 않는다. 그림자 자체의 단계·값은 elevation.md를 따른다. 이 절은 그중 유리 재질에만 적용되는 위계 규칙이다.

스티키 헤더는 그래디언트 마스크로: 스티키 헤더 아래에 1px 하드 보더를 긋지 마라. 플로팅 크롬이 콘텐츠와 실제로 겹치는 부분에만 작은 블러 또는 그래디언트 마스크를 페이드시켜, 경계가 아니라 스크롤 엣지 효과로 읽히게 한다.

materialize 전환: 유리·블러 표면의 진입·퇴장은 opacity 하나만 바꾸지 마라. 블러 반경과 scale을 함께 애니메이션해 "머티리얼이 실제로 도착한다"는 인상을 만든다. 필터 애니메이션의 비용·감소 모션 경로는 svg-filters.md §6·§7을 그대로 따른다 — 이 절이 그 규칙을 면제하지 않는다.

.sheet {
  opacity: 0; transform: scale(0.96); backdrop-filter: blur(0px);
  transition: opacity var(--dur-quick) var(--ease-out), transform var(--dur-quick) var(--ease-out),
              backdrop-filter var(--dur-quick) var(--ease-out);   /* 새 duration 리터럴을 만들지 않는다(tokens.md §4) */
}
.sheet.is-open {
  opacity: 1; transform: scale(1); backdrop-filter: blur(18px) saturate(1.4);
}

prefers-reduced-transparency 대체: 이 미디어 쿼리는 Chromium 계열(Chrome·Edge)에만 있고 Firefox·Safari는 지원하지 않는다[WEB-BASELINE] — 지원 브라우저에서만 통하는 점진적 향상으로 다룬다. 감지되면 반투명 표면의 배경 불투명도를 올리고 블러를 낮춘다. 미지원 브라우저에서도 svg-filters.md §7의 저사양 감지(reduce-effects 클래스)와 "필터를 전부 끈 상태에서 성립해야 한다"는 원칙이 안전망 역할을 한다.

@media (prefers-reduced-transparency: reduce) {
  .glass { backdrop-filter: blur(6px); background: rgba(255, 255, 255, 0.82); }
}

그라디언트를 재질의 일부로 쓸 때(예: 듀오톤·머티리얼 틴트)의 보간 공간 선택은 color.md를 참조한다.

4. 유리 체크리스트(svg-filters.md §8에 더한다 — 구현 중 자기 점검, 납품 판정은 preflight.md X3·A3)

  • 유리 위 본문 텍스트가 배경이 바뀌는 모든 스크롤 위치에서 대비 기준을 넘는다(§3 vibrancy)
  • 반투명 표면끼리 겹치지 않거나, 아래쪽 불투명도를 올렸다(§3)
  • 굴절(backdrop-filter: url())은 @supports 안에 있고 blur() 폴백이 있다. 굴절 없이도 화면이 성립한다(§2-3)
  • feImage 변위 지도 크기가 요소 크기와 같고, 리사이즈 시 다시 만든다(§2-3)
  • 테두리 광을 돌린다면 @property 배경 재도색이 아니라 회전 레이어이며, 감소 모션에서 멈춘다(§1-1)
  • prefers-reduced-transparency 지원 브라우저에서 불투명도가 올라간다(§3)