designpaca/research/svg/02-recipes.md
Yun Chan 8808c672dc designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포.

스킬 (packages/skill)
- SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로
  본문은 절차와 인덱스만, 지식은 references/ 로 분리
- 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기
- 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외
- 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙,
  SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침

설치 CLI (packages/cli, packages/core)
- npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md
- 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다
- 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성
- AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입
- Windsurf 는 12,000자 상한 초과 시 설치를 차단

배포 (build/ci, .forgejo/workflows)
- 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러
  → draft 릴리스 → Cloudflare Pages. 재실행 멱등

근거 (research/)
- 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다

테스트 22개 통과 (core 16 · cli 6)
2026-08-20 10:48:00 +09:00

1670 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 02. SVG 필터 레시피북 — 바로 붙여넣는 완성 코드
> 22개 레시피. 모든 코드는 **생략 없는 완성본**이며, 별도 표기가 없으면 **Chromium 151에서 실제 렌더링 검증 완료**다.
> 성능 등급: **상**=스크롤/애니메이션 중에도 안전 · **중**=정적이면 안전, 애니메이션은 작은 면적만 · **하**=큰 면적·모바일·상시 애니메이션 금물
**공통 전제**: 아래 컨테이너를 문서 어딘가(보통 `</body>` 직전)에 한 번 넣고, 각 레시피의 `<filter>`를 그 안에 모은다.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<defs>
<!-- 여기에 filter들 -->
</defs>
</svg>
```
---
## 목차
| # | 레시피 | 성능 | 핵심 프리미티브 |
|---|---|---|---|
| 01 | Gooey / Metaball | 중 | feGaussianBlur + feColorMatrix |
| 02 | 필름 그레인 오버레이 (CSS만) | 상 | feTurbulence (data URI) |
| 03 | 그레이니 그라디언트 | 상 | feTurbulence + CSS filter |
| 04 | 종이 질감 | 중 | feTurbulence + feDiffuseLighting |
| 05 | Liquid Glass (굴절 유리) | 중 | feImage + feDisplacementMap + backdrop-filter |
| 06 | Liquid Glass + 색수차 | 하 | 3× feDisplacementMap |
| 07 | 프로스티드 글래스 (폴백 겸용) | 상 | backdrop-filter + noise |
| 08 | 크로마틱 애버레이션 / RGB Split | 중 | feOffset + feColorMatrix + feBlend |
| 09 | 손그림 러프 엣지 | 중 | feTurbulence + feDisplacementMap |
| 10 | 잉크 번짐 | 중 | 변위 + 블러 + 알파 대비 |
| 11 | Squigglevision (애니메이션 손그림) | 하 | seed 순환 |
| 12 | 물결 텍스트 | 하 | SMIL baseFrequency |
| 13 | 듀오톤 / 그라디언트 맵 | 상 | feColorMatrix + feComponentTransfer |
| 14 | 포스터화 / 트라이톤 | 상 | feComponentTransfer discrete |
| 15 | 디더 / 임계값 | 중 | 노이즈 가산 + discrete |
| 16 | 리소그래프 인쇄 | 중 | mix-blend-mode + grain + 오프셋 |
| 17 | 텍스트 아웃라인 | 상 | feMorphology |
| 18 | 스티커 | 상 | feMorphology + feDropShadow |
| 19 | 네온 글로우 | 중 | 체인 feDropShadow |
| 20 | 스포트라이트 | 중 | feSpotLight + feDiffuseLighting |
| 21 | 광택/엠보스 | 중 | feSpecularLighting |
| 22 | 글리치 밴드 | 하 | feFlood 밴드 + feDisplacementMap |
---
## 01. Gooey / Metaball
**언제 쓰나** — 떠다니는 블롭 배경, 확장되는 FAB 메뉴, 로딩 인디케이터, 유기적 로고 모션. 개별 도형들이 **액체처럼 서로 융합**해야 할 때.
**원리** — 블러로 알파를 번지게 한 뒤, `feColorMatrix`로 알파 대비를 극단적으로 올려 다시 날카로운 경계를 만든다. 두 도형의 번진 알파가 겹치는 지점에서 임계값을 넘으면 하나로 붙는다.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="goo" x="-25%" y="-25%" width="150%" height="150%"
color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation="9" result="blur"/>
<feColorMatrix in="blur" type="matrix"
values="1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 20 -9" result="goo"/>
<feComposite in="SourceGraphic" in2="goo" operator="atop"/>
</filter>
</defs>
</svg>
<div class="goo-menu">
<span></span><span></span><span></span>
</div>
```
```css
.goo-menu {
filter: url(#goo);
display: flex;
gap: 6px;
align-items: center;
/* 필터 영역이 잘리지 않도록 여백 확보 */
padding: 24px;
}
.goo-menu span {
width: 56px;
height: 56px;
border-radius: 50%;
background: #34d399;
display: block;
}
@media (prefers-reduced-motion: reduce) {
.goo-menu { filter: none; }
}
```
**파라미터 가이드**
| 값 | 효과 |
|---|---|
| `stdDeviation` 5 | 도형이 아주 가까워야 붙음. 미세한 융합 |
| `stdDeviation` 9~12 | 표준. 목(neck)이 자연스럽게 늘어남 |
| `stdDeviation` 20+ | 멀리서도 붙음. 형태가 뭉개짐 |
| 알파 행렬 `M`(=20) ↑ | 경계가 더 날카로움. 12~30 권장 |
| `O/M` (=9/20=0.45) ↑ | 형태가 더 수축. 0.35~0.5 권장 |
**마무리 방식 선택**
| 코드 | 결과 |
|---|---|
| `<feBlend in="SourceGraphic" in2="goo"/>` | 원본을 위에 얹음. 원본 색이 선명하게 살아남 |
| `<feComposite in="SourceGraphic" in2="goo" operator="atop"/>` | goo 영역 안에서만 원본. **모서리가 뾰족한 도형일 때 필수** |
| (마무리 없음) | goo만. 알파 대비 결과의 색이 그대로 나옴 |
**함정**
- **필터 컨테이너 안에 텍스트를 넣지 마라.** 텍스트가 블러+임계값을 거쳐 판독 불가능한 덩어리가 된다(검증됨). 텍스트는 필터 밖 별도 레이어에 절대배치로 얹어라.
- 컨테이너에 여백이 없으면 블롭이 필터 영역 경계에서 잘린다.
**성능: 중** — 정적이면 문제없음. 블롭이 움직이면 매 프레임 블러 재계산이라 면적이 크면 급격히 무거워진다. 400×400px 이하로 제한하고 `will-change: filter`**쓰지 마라**(§03 참고).
---
## 02. 필름 그레인 오버레이 (SVG 없이 CSS만으로)
**언제 쓰나** — 어떤 배경에도 얹는 범용 텍스처. 밋밋한 그라디언트/단색을 아날로그하게 만든다. **가장 자주 쓰게 될 레시피.**
**원리** — SVG를 data URI 배경 이미지로 넣으면 **한 번만 래스터화**되어 캐시된다. 요소에 `filter`를 거는 것과 달리 리페인트마다 재계산되지 않는다.
```css
.grainy {
position: relative;
isolation: isolate; /* 블렌드가 바깥으로 새지 않게 */
background: linear-gradient(135deg, #4f46e5, #ec4899);
}
.grainy::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.55;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
}
```
**파라미터 가이드**
| 조절 | 방법 |
|---|---|
| 입자 크기 | data URI 안 `baseFrequency`: `0.6`(굵음) ~ `0.95`(미세) |
| 거칠기 | `numOctaves`: 1(부드러움) ~ 4(거침) |
| 강도 | CSS `opacity`: 0.15(은은) ~ 0.7(강함) |
| 룩 | `mix-blend-mode`: `overlay`(대비 유지) · `soft-light`(부드러움) · `multiply`(어둡게) · `screen`(밝게) |
**밝은 배경용 변형**`overlay`는 밝은 배경에서 약하다. `multiply` + 낮은 opacity로 바꿔라.
```css
.grainy-light::after {
mix-blend-mode: multiply;
opacity: 0.25;
}
```
**URL 인코딩 필수 문자**: `#``%23`, `<``%3C`, `>``%3E`, `%``%25`, 큰따옴표는 작은따옴표로.
**성능: 상** — 정적 배경 이미지라 리페인트 비용이 사실상 0. **가장 안전한 노이즈 방법.**
---
## 03. 그레이니 그라디언트
**언제 쓰나** — 그라디언트의 밴딩(계단 현상)을 감추면서 동시에 질감을 준다. 히어로 배경, 카드 표면.
```css
.grainy-gradient {
position: relative;
isolation: isolate;
background: radial-gradient(circle at 30% 20%, #7c3aed, transparent 60%),
radial-gradient(circle at 75% 70%, #06b6d4, transparent 55%),
#0f172a;
}
.grainy-gradient::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
filter: contrast(170%) brightness(1000%);
mix-blend-mode: overlay;
opacity: 0.18;
}
```
**핵심**: `contrast(170%) brightness(1000%)`가 회색 뭉치를 흑백 점으로 극단화한다. 이 두 값이 그레인의 성격을 결정한다.
| 목표 | 값 |
|---|---|
| 미세한 필름 그레인 | `contrast(120%) brightness(300%)`, opacity 0.12 |
| 거친 인쇄 노이즈 | `contrast(300%) brightness(1500%)`, opacity 0.25 |
| 스노우/TV 노이즈 | `contrast(500%) brightness(2000%)`, opacity 0.4 |
> 검증됨. `opacity: 0.18`은 의도적으로 아주 은은하다 — 어두운 영역에서 밴딩이 사라지는 것으로 확인된다. 그레인이 "보이길" 원하면 0.3 이상으로 올려라.
**성능: 상** — 배경 이미지 + CSS 단축 필터. GPU 가속이 잘 된다.
---
## 04. 종이 질감
**언제 쓰나** — 에디토리얼 레이아웃, 인쇄물 느낌의 카드, 아날로그 브랜딩.
**원리**`feTurbulence`의 알파 노이즈를 **높이맵**으로 삼아 `feDiffuseLighting`으로 조명한다. 실제 종이 섬유의 미세한 요철을 시뮬레이션한다.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="paper" x="0%" y="0%" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.04"
numOctaves="5" seed="3" result="noise"/>
<feDiffuseLighting in="noise" lighting-color="#e8e0d0"
surfaceScale="2" result="lit">
<feDistantLight azimuth="45" elevation="60"/>
</feDiffuseLighting>
<feComposite in="lit" in2="SourceAlpha" operator="in"/>
</filter>
</defs>
</svg>
<div class="paper-card">
<div class="paper-surface"></div>
<div class="paper-content">
<h2>Editorial</h2>
<p>텍스트는 반드시 필터 밖 레이어에 둔다.</p>
</div>
</div>
```
```css
.paper-card { position: relative; width: 420px; aspect-ratio: 3/2; }
.paper-surface {
position: absolute; inset: 0;
background: #e8e0d0;
border-radius: 6px;
filter: url(#paper);
}
.paper-content {
position: relative; /* 필터 위에 겹침, 필터 영향 없음 */
padding: 32px;
color: #2b2b28;
}
```
**파라미터 가이드**
| 목표 | baseFrequency | numOctaves | surfaceScale | elevation |
|---|---|---|---|---|
| 매끈한 카드지 | 0.02 | 3 | 1 | 70 |
| 표준 종이 | 0.04 | 5 | 2 | 60 |
| 거친 수제지 | 0.06 | 5 | 3.5 | 45 |
| 캔버스/천 | 0.08 0.02 | 4 | 3 | 50 |
| 스투코 벽 | 0.05 | 4 | 20 | 40 |
- `elevation`을 낮출수록 그림자가 길어져 **대비가 강해진다.**
- `lighting-color`를 종이 색과 같게 맞추면 자연스럽다. 다르게 하면 조명 색이 입혀진다.
- **`feComposite operator="in"`을 빼면 필터 영역 전체가 종이로 칠해진다.**
**성능: 중**`numOctaves="5"`는 무겁다. 정적 배경이면 문제없지만, 애니메이션은 금지. 반복 배경이라면 **한 번 만들어 PNG로 굽는 것**이 최선이다.
---
## 05. Liquid Glass (굴절 유리)
**언제 쓰나** — iOS 26 스타일 유리 UI, 플로팅 툴바/독, 모달 헤더. 배경이 **실제로 굴절**되어야 할 때(단순 블러와 차원이 다르다).
**원리**`backdrop-filter: url(#id)`로 배경 픽셀을 필터에 통과시키고, 미리 만든 변위맵으로 가장자리에서만 굴절시킨다. **중앙은 중립(128), 가장자리는 극단값**인 맵이 렌즈 효과의 전부다.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="lens" x="0%" y="0%" width="100%" height="100%"
color-interpolation-filters="sRGB">
<feImage preserveAspectRatio="none" x="0" y="0" width="220" height="88" result="map"
href="data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='88'%3E%3Cdefs%3E%3ClinearGradient id='rx' x1='0' y1='0' x2='1' y2='0'%3E%3Cstop offset='0' stop-color='rgb(0,0,0)'/%3E%3Cstop offset='0.28' stop-color='rgb(128,0,0)'/%3E%3Cstop offset='0.72' stop-color='rgb(128,0,0)'/%3E%3Cstop offset='1' stop-color='rgb(255,0,0)'/%3E%3C/linearGradient%3E%3ClinearGradient id='gy' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='rgb(0,0,0)'/%3E%3Cstop offset='0.28' stop-color='rgb(0,128,0)'/%3E%3Cstop offset='0.72' stop-color='rgb(0,128,0)'/%3E%3Cstop offset='1' stop-color='rgb(0,255,0)'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='220' height='88' rx='44' fill='rgb(128,128,128)'/%3E%3Crect width='220' height='88' rx='44' fill='url(%23rx)' style='mix-blend-mode:screen'/%3E%3Crect width='220' height='88' rx='44' fill='url(%23gy)' style='mix-blend-mode:screen'/%3E%3C/svg%3E"/>
<feDisplacementMap in="SourceGraphic" in2="map" scale="-60"
xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs>
</svg>
<div class="glass-pill">Liquid Glass</div>
```
```css
.glass-pill {
/* 맵 크기와 정확히 일치시켜야 한다 */
width: 220px;
height: 88px;
border-radius: 44px;
display: grid;
place-items: center;
color: #fff;
font-weight: 600;
letter-spacing: 0.02em;
backdrop-filter: url(#lens) brightness(1.06) saturate(1.25);
border: 1px solid rgba(255, 255, 255, 0.28);
box-shadow:
inset 0 1px 1px rgba(255, 255, 255, 0.7),
inset 0 -1px 1px rgba(255, 255, 255, 0.3),
0 10px 30px rgba(0, 0, 0, 0.45);
}
/* Chromium 외 브라우저 폴백 */
@supports not (backdrop-filter: url(#lens)) {
.glass-pill { backdrop-filter: blur(14px) saturate(1.4); }
}
```
**변위맵 원문** (인코딩 전 — 크기를 바꿀 때 이걸 수정해 다시 인코딩)
```xml
<svg xmlns="http://www.w3.org/2000/svg" width="220" height="88">
<defs>
<linearGradient id="rx" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="rgb(0,0,0)"/>
<stop offset="0.28" stop-color="rgb(128,0,0)"/>
<stop offset="0.72" stop-color="rgb(128,0,0)"/>
<stop offset="1" stop-color="rgb(255,0,0)"/>
</linearGradient>
<linearGradient id="gy" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="rgb(0,0,0)"/>
<stop offset="0.28" stop-color="rgb(0,128,0)"/>
<stop offset="0.72" stop-color="rgb(0,128,0)"/>
<stop offset="1" stop-color="rgb(0,255,0)"/>
</linearGradient>
</defs>
<rect width="220" height="88" rx="44" fill="rgb(128,128,128)"/>
<rect width="220" height="88" rx="44" fill="url(#rx)" style="mix-blend-mode:screen"/>
<rect width="220" height="88" rx="44" fill="url(#gy)" style="mix-blend-mode:screen"/>
</svg>
```
**파라미터 가이드**
| 조절 | 방법 |
|---|---|
| 굴절 강도 | `scale`: 30(약함) ~ 90(강함). **음수가 볼록 렌즈** |
| 굴절 방향 반전 | `scale` 부호 뒤집기 (오목 렌즈) |
| 굴절이 몰리는 폭 | 그라디언트 stop `0.28`/`0.72``0.15`/`0.85`로 → 가장자리에 더 집중 |
| 유리 두께감 | `box-shadow``inset` 하이라이트 강도 |
| 배경 밝기/채도 | `backdrop-filter``brightness()` `saturate()` |
**브라우저 지원**
- `backdrop-filter: url(#id)`는 **Chromium 계열만 실제로 렌더**한다. Safari·Firefox는 미지원(Firefox에는 기능 요청이 올라와 있음).
- ⚠️ `CSS.supports('backdrop-filter','url(#x)')`**값 파싱만** 검사하므로 신뢰할 수 없다(Chromium에서 true 확인, 다른 엔진도 true를 반환할 가능성이 높음). **폴백은 `@supports`가 아니라 "폴백이 그 자체로 괜찮게 보이도록" 설계하라** — 레시피 07을 기본으로 깔고 그 위에 렌즈를 얹는 방식.
**함정**
- `backdrop-filter`의 필터 영역은 요소 크기에 **자동으로 맞춰지지 않는다.** 맵의 `width/height`와 요소의 `width/height`가 어긋나면 굴절이 엉뚱한 곳에 생긴다. 크기가 다른 버튼마다 **별도의 맵**을 만들어라.
- 반응형으로 크기가 계속 변하는 요소에는 부적합. 고정 크기 칩/독/툴바에 쓴다.
**성능: 중**`feImage`는 한 번 래스터화 후 캐시된다. `scale`만 애니메이션하면 맵 재생성이 없어 비교적 싸다. 반대로 요소 크기를 애니메이션하면 매 프레임 맵 재계산 → 매우 비쌈.
---
## 06. Liquid Glass + 색수차 (프리즘 유리)
**언제 쓰나** — 프리미엄 히어로, 프로덕트 쇼케이스. 유리 가장자리에서 **빛이 무지개로 분산**되는 효과.
**원리** — 같은 변위맵을 R/G/B 각각 **다른 `scale`** 로 3번 적용한 뒤 `screen`으로 합친다. 굴절률이 파장마다 다른 물리 현상을 흉내낸다.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="lensRGB" x="0%" y="0%" width="100%" height="100%"
color-interpolation-filters="sRGB">
<feImage preserveAspectRatio="none" x="0" y="0" width="200" height="96" result="map"
href="data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='96'%3E%3Cdefs%3E%3ClinearGradient id='rx' x1='0' y1='0' x2='1' y2='0'%3E%3Cstop offset='0' stop-color='rgb(0,0,0)'/%3E%3Cstop offset='0.3' stop-color='rgb(128,0,0)'/%3E%3Cstop offset='0.7' stop-color='rgb(128,0,0)'/%3E%3Cstop offset='1' stop-color='rgb(255,0,0)'/%3E%3C/linearGradient%3E%3ClinearGradient id='gy' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='rgb(0,0,0)'/%3E%3Cstop offset='0.3' stop-color='rgb(0,128,0)'/%3E%3Cstop offset='0.7' stop-color='rgb(0,128,0)'/%3E%3Cstop offset='1' stop-color='rgb(0,255,0)'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='200' height='96' rx='48' fill='rgb(128,128,128)'/%3E%3Crect width='200' height='96' rx='48' fill='url(%23rx)' style='mix-blend-mode:screen'/%3E%3Crect width='200' height='96' rx='48' fill='url(%23gy)' style='mix-blend-mode:screen'/%3E%3C/svg%3E"/>
<feDisplacementMap in="SourceGraphic" in2="map" scale="-56"
xChannelSelector="R" yChannelSelector="G" result="dR"/>
<feColorMatrix in="dR" type="matrix" result="cR"
values="1 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 1 0"/>
<feDisplacementMap in="SourceGraphic" in2="map" scale="-60"
xChannelSelector="R" yChannelSelector="G" result="dG"/>
<feColorMatrix in="dG" type="matrix" result="cG"
values="0 0 0 0 0
0 1 0 0 0
0 0 0 0 0
0 0 0 1 0"/>
<feDisplacementMap in="SourceGraphic" in2="map" scale="-64"
xChannelSelector="R" yChannelSelector="G" result="dB"/>
<feColorMatrix in="dB" type="matrix" result="cB"
values="0 0 0 0 0
0 0 0 0 0
0 0 1 0 0
0 0 0 1 0"/>
<feBlend in="cR" in2="cG" mode="screen" result="rg"/>
<feBlend in="rg" in2="cB" mode="screen"/>
</filter>
</defs>
</svg>
<div class="prism-pill"></div>
```
```css
.prism-pill {
width: 200px;
height: 96px;
border-radius: 48px;
backdrop-filter: url(#lensRGB);
border: 1px solid rgba(255, 255, 255, 0.4);
box-shadow:
inset 0 1px 2px rgba(255, 255, 255, 0.6),
0 10px 24px rgba(0, 0, 0, 0.5);
}
@supports not (backdrop-filter: url(#lensRGB)) {
.prism-pill { backdrop-filter: blur(12px) saturate(1.6); }
}
```
**파라미터 가이드** — 3개 `scale`의 **차이**가 색수차의 세기다.
| scale 조합 | 결과 |
|---|---|
| `-58 / -60 / -62` | 아주 미묘한 프리즘. **프로덕션 권장** |
| `-56 / -60 / -64` | 뚜렷한 무지개 테두리 |
| `-50 / -60 / -70` | 과장된 사이키델릭 |
> 검증됨. 단 `-56/-64/-72`처럼 차이를 크게 벌리면 배경이 줄무늬 무지개로 완전히 분해되니 주의.
**성능: 하** — 변위 3회 + 색행렬 3회 + 블렌드 2회 = 프리미티브 8개. 페이지에 **1~2개만** 쓴다. 모바일에서는 레시피 05나 07로 폴백하라.
---
## 07. 프로스티드 글래스 + 노이즈 (범용 폴백)
**언제 쓰나** — 모든 브라우저에서 동작해야 하는 유리 UI. **레시피 05/06의 기본 레이어로 항상 깔아둔다.**
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="frostNoise" x="0%" y="0%" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.9"
numOctaves="3" stitchTiles="stitch" result="n"/>
<feColorMatrix in="n" type="saturate" values="0"/>
</filter>
</defs>
</svg>
<div class="frost">FROSTED</div>
```
```css
.frost {
position: relative;
width: 230px;
height: 110px;
border-radius: 16px;
display: grid;
place-items: center;
font-weight: 700;
letter-spacing: 0.08em;
color: #fff;
backdrop-filter: blur(12px) saturate(1.4);
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
/* 유리 표면의 미세 결정감 */
.frost::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: #fff;
filter: url(#frostNoise);
mix-blend-mode: overlay;
opacity: 0.35;
}
```
**파라미터 가이드**
| 조절 | 값 |
|---|---|
| 흐림 정도 | `blur()` 6px(얇은 유리) ~ 24px(두꺼운 간유리) |
| 유리 색조 | `background: rgba(...)` 알파 0.06~0.2 |
| 표면 결정감 | `::after``opacity` 0.15~0.5 |
| 결정 크기 | `baseFrequency` 0.6~0.95 |
**성능: 상**`backdrop-filter: blur()`는 잘 가속된다. 노이즈 오버레이는 정적이므로 캐시된다. 단, `backdrop-filter` 자체가 배경 합성을 강제하므로 **화면 전체에 깔지는 마라.**
---
## 08. 크로마틱 애버레이션 / RGB Split
**언제 쓰나** — 사이버펑크/글리치 타이포, 레트로 CRT, VHS 룩, 호버 시 순간 왜곡.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="chromatic" x="-20%" y="-20%" width="140%" height="140%"
color-interpolation-filters="sRGB">
<feOffset in="SourceGraphic" dx="-3" dy="0" result="rShift"/>
<feColorMatrix in="rShift" type="matrix" result="red"
values="1 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 1 0"/>
<feColorMatrix in="SourceGraphic" type="matrix" result="green"
values="0 0 0 0 0
0 1 0 0 0
0 0 0 0 0
0 0 0 1 0"/>
<feOffset in="SourceGraphic" dx="3" dy="0" result="bShift"/>
<feColorMatrix in="bShift" type="matrix" result="blue"
values="0 0 0 0 0
0 0 0 0 0
0 0 1 0 0
0 0 0 1 0"/>
<feBlend in="red" in2="green" mode="screen" result="rg"/>
<feBlend in="rg" in2="blue" mode="screen"/>
</filter>
</defs>
</svg>
<h1 class="chroma">GLITCH</h1>
```
```css
.chroma {
font-size: 44px;
font-weight: 800;
color: #fff; /* 흰색 소스여야 RGB 분리가 선명하다 */
filter: url(#chromatic);
}
```
**렌즈 색수차 변형** (블러 포함, 사진에 적용)
```xml
<filter id="chromaticLens" x="-10%" y="-10%" width="120%" height="120%"
color-interpolation-filters="sRGB">
<feColorMatrix in="SourceGraphic" type="matrix"
values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feOffset dx="2" dy="0"/>
<feGaussianBlur stdDeviation="2" result="redChannel"/>
<feColorMatrix in="SourceGraphic" type="matrix"
values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feGaussianBlur stdDeviation="0.5" result="greenChannel"/>
<feColorMatrix in="SourceGraphic" type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0"/>
<feOffset dx="-2" dy="0"/>
<feGaussianBlur stdDeviation="2" result="blueChannel"/>
<feBlend in="redChannel" in2="greenChannel" mode="screen" result="redGreen"/>
<feBlend in="redGreen" in2="blueChannel" mode="screen"/>
</filter>
```
**파라미터 가이드**
| 조절 | 값 |
|---|---|
| 분리 강도 | `dx`: ±1(미묘) / ±3(표준) / ±8(강한 글리치) |
| 수직 분리 | `dy`도 함께 주면 대각 분리 |
| 초점 흐림 | 각 채널 `stdDeviation` — G는 낮게(0.5), R/B는 높게(2) 두면 렌즈 느낌 |
**왜 `screen`인가** — R/G/B 각각을 분리하면 나머지 채널이 0(검정)이다. `screen`은 가산 합성이라 세 채널을 원래 색으로 되돌린다. `normal`이면 마지막 채널만 보인다.
**함정****소스가 유채색이면 결과가 탁해진다.** 흰색/밝은 회색 텍스트에서 가장 잘 나온다.
**성능: 중** — 프리미티브 7개지만 전부 가벼운 연산. 호버 트리거로 짧게 쓰는 것을 권장.
---
## 09. 손그림 러프 엣지
**언제 쓰나** — 딱딱한 사각형/버튼을 손으로 그린 듯 만들기. 일러스트레이션 톤의 브랜드, 노트/스케치 UI.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="rough" x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence type="fractalNoise" baseFrequency="0.03"
numOctaves="4" seed="12" result="n"/>
<feDisplacementMap in="SourceGraphic" in2="n" scale="9"
xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs>
</svg>
<div class="rough-card">Hand drawn</div>
```
```css
.rough-card {
width: 220px;
height: 130px;
background: #f59e0b;
border-radius: 10px;
filter: url(#rough);
}
```
**파라미터 가이드**
| 목표 | baseFrequency | numOctaves | scale |
|---|---|---|---|
| 아주 미세한 손떨림 | 0.02 | 2 | 3 |
| 표준 손그림 | 0.03 | 4 | 9 |
| 거친 크레용 | 0.05 | 5 | 14 |
| 찢어진 종이 | 0.08 | 3 | 20 |
- **`seed`를 바꾸면 다른 "손"이 된다.** 여러 요소에 같은 필터를 쓰면 전부 똑같이 흔들려 부자연스럽다. seed만 다른 필터를 3~4개 만들어 돌려 쓰면 살아난다.
```xml
<filter id="rough1"><feTurbulence type="fractalNoise" baseFrequency="0.03" numOctaves="4" seed="1" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale="9" xChannelSelector="R" yChannelSelector="G"/></filter>
<filter id="rough2"><feTurbulence type="fractalNoise" baseFrequency="0.03" numOctaves="4" seed="7" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale="9" xChannelSelector="R" yChannelSelector="G"/></filter>
<filter id="rough3"><feTurbulence type="fractalNoise" baseFrequency="0.03" numOctaves="4" seed="23" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale="9" xChannelSelector="R" yChannelSelector="G"/></filter>
```
**텍스트에 적용할 때**`scale`을 **4 이하**로. 그 이상은 판독성이 급격히 떨어진다.
**성능: 중** — 정적이면 안전. seed/scale 애니메이션은 §11 참고.
---
## 10. 잉크 번짐 / 마커 필
**언제 쓰나** — 스탬프, 마커 하이라이트, 리소/실크스크린 텍스트, 손글씨 느낌 강조.
**원리** — 변위로 윤곽을 흐트러뜨리고 → 블러로 번지게 하고 → 알파 대비로 다시 단단한 경계를 만든다. 잉크가 종이에 스며든 뒤 마른 결과와 같은 순서다.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="inkbleed" x="-20%" y="-20%" width="140%" height="140%"
color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.05"
numOctaves="4" seed="7" result="n"/>
<feDisplacementMap in="SourceGraphic" in2="n" scale="4"
xChannelSelector="R" yChannelSelector="G" result="d"/>
<feGaussianBlur in="d" stdDeviation="1.2" result="b"/>
<feColorMatrix in="b" type="matrix"
values="1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 14 -6"/>
</filter>
</defs>
</svg>
<span class="ink">INK</span>
```
```css
.ink {
font-size: 40px;
font-weight: 800;
color: #fff;
filter: url(#inkbleed);
}
```
**파라미터 가이드**
| 조절 | 효과 |
|---|---|
| `scale` 2~6 | 윤곽의 불규칙함 |
| `stdDeviation` 0.8~2.5 | 번짐 정도. 크면 글자가 뭉친다 |
| 알파 행렬 `M`(14) | 크면 경계가 날카롭고 "마른 잉크", 작으면 "젖은 잉크" |
| `O/M`(6/14≈0.43) | 크면 글자가 얇아지고, 작으면 두꺼워진다 |
| `baseFrequency` 0.03~0.09 | 번짐 결의 크기 |
**성능: 중** — 텍스트처럼 작은 면적에만.
---
## 11. Squigglevision (애니메이션 손그림)
**언제 쓰나** — 애니메이션 일러스트, 놀이/교육 UI, 브랜드 마스코트. **저프레임 스톱모션 느낌**이 핵심이라 60fps로 돌리면 안 된다.
**원리** — 3~4개의 서로 다른 `seed`를 **낮은 프레임레이트로 순환**시킨다. 매 프레임 새 노이즈를 만드는 게 아니라 몇 장의 "그림"을 번갈아 보여주는 것이라 훨씬 싸고, 룩도 더 정확하다.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="squiggle" x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence id="squiggleNoise" type="fractalNoise"
baseFrequency="0.02" numOctaves="3" seed="1" result="n"/>
<feDisplacementMap in="SourceGraphic" in2="n" scale="6"
xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs>
</svg>
<div class="squiggle-art">
<svg viewBox="0 0 200 120" width="200">
<rect x="20" y="20" width="160" height="80" rx="8"
fill="none" stroke="#0ea5e9" stroke-width="4"/>
<path d="M40 90 L80 40 L120 80 L160 30" fill="none"
stroke="#f43f5e" stroke-width="4" stroke-linecap="round"/>
</svg>
</div>
```
```css
.squiggle-art { filter: url(#squiggle); }
@media (prefers-reduced-motion: reduce) {
.squiggle-art { filter: none; }
}
```
```js
(function squigglevision() {
const noise = document.getElementById('squiggleNoise');
if (!noise) return;
// 접근성: 감소 모션을 켠 사용자는 애니메이션 없이 정지 상태 유지
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
if (mq.matches) return;
const seeds = [1, 2, 3, 4];
let i = 0;
let timer = null;
function tick() {
noise.setAttribute('seed', String(seeds[i++ % seeds.length]));
}
function start() {
if (timer === null) timer = setInterval(tick, 120); // ≈8fps
}
function stop() {
if (timer !== null) { clearInterval(timer); timer = null; }
}
// 탭이 백그라운드면 멈춘다 (배터리 보호)
document.addEventListener('visibilitychange', () => {
document.hidden ? stop() : start();
});
// 화면 밖이면 멈춘다
const target = document.querySelector('.squiggle-art');
if (target && 'IntersectionObserver' in window) {
new IntersectionObserver(entries => {
entries[0].isIntersecting ? start() : stop();
}, { threshold: 0 }).observe(target);
} else {
start();
}
mq.addEventListener('change', e => { e.matches ? stop() : start(); });
})();
```
**파라미터 가이드**
| 조절 | 값 |
|---|---|
| 프레임레이트 | `setInterval` 100~200ms. **80ms 미만은 노이즈 지글거림으로 보인다** |
| 흔들림 크기 | `scale` 3(미묘) ~ 10(과장) |
| 시드 개수 | 3~4개. 많을수록 불규칙하지만 반복 주기가 길어짐 |
**성능: 하** — feTurbulence를 초당 8회 재생성한다. **반드시** IntersectionObserver + visibilitychange로 게이팅하고, 적용 면적을 300×300px 이하로 제한하라. 모바일에서는 아예 끄는 것을 권장(§03).
---
## 12. 물결 텍스트 (SMIL)
**언제 쓰나** — 히어로 타이틀의 미묘한 액체 모션, 수중 테마, 열기 아지랑이.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="wave" x="-15%" y="-40%" width="130%" height="180%">
<feTurbulence type="turbulence" baseFrequency="0.008 0.04"
numOctaves="2" seed="2" result="n">
<animate attributeName="baseFrequency" dur="8s"
values="0.008 0.04;0.012 0.055;0.008 0.04"
repeatCount="indefinite"/>
</feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="n" scale="7"
xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs>
</svg>
<h1 class="wave">LIQUID</h1>
```
```css
.wave {
font-size: 40px;
font-weight: 800;
color: #facc15;
filter: url(#wave);
}
@media (prefers-reduced-motion: reduce) {
.wave { filter: none; }
}
```
**파라미터 가이드**
| 조절 | 효과 |
|---|---|
| `baseFrequency="0.008 0.04"` | x는 크게, y는 촘촘 → **가로로 흐르는 물결**. 값을 뒤집으면 세로 물결 |
| `scale` 4~8 | 텍스트 판독성 한계는 대략 8 |
| `dur` 6~12s | 느릴수록 고급스럽다. 3s 이하는 어지럽다 |
| `type="turbulence"` | 물결에 적합. `fractalNoise`는 더 부드럽고 뭉근함 |
**대안: `scale`만 애니메이션**`baseFrequency`를 바꾸면 매 프레임 노이즈를 새로 만든다. `scale`만 애니메이션하면 **노이즈는 캐시되고 변위만 바뀌어 훨씬 싸다.**
```xml
<filter id="waveCheap" x="-15%" y="-40%" width="130%" height="180%">
<feTurbulence type="turbulence" baseFrequency="0.01 0.05"
numOctaves="2" seed="2" result="n"/>
<feDisplacementMap in="SourceGraphic" in2="n" scale="0"
xChannelSelector="R" yChannelSelector="G">
<animate attributeName="scale" dur="6s"
values="2;9;2" repeatCount="indefinite"/>
</feDisplacementMap>
</filter>
```
> 룩은 "흐르는" 대신 "숨쉬는" 쪽에 가깝지만 비용이 크게 낮다. 대부분의 경우 이쪽으로 충분하다.
**성능: 하** (`baseFrequency` 애니메이션) / **중** (`scale` 애니메이션)
---
## 13. 듀오톤 / 그라디언트 맵
**언제 쓰나** — 브랜드 컬러로 사진 톤을 통일. 잡다한 스톡 이미지들을 한 화면에 놓아도 일관돼 보이게 만드는 가장 강력한 기법.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="duotone" color-interpolation-filters="sRGB">
<!-- 1) Rec.709 휘도로 그레이스케일 -->
<feColorMatrix type="matrix" values="
0.2126 0.7152 0.0722 0 0
0.2126 0.7152 0.0722 0 0
0.2126 0.7152 0.0722 0 0
0 0 0 1 0"/>
<!-- 2) 그림자 #0D1A59 → 하이라이트 #FA591A 로 리매핑 -->
<feComponentTransfer>
<feFuncR type="table" tableValues="0.05 0.98"/>
<feFuncG type="table" tableValues="0.10 0.35"/>
<feFuncB type="table" tableValues="0.35 0.10"/>
</feComponentTransfer>
</filter>
</defs>
</svg>
<img class="duo" src="photo.jpg" alt="설명">
```
```css
.duo {
filter: url(#duotone);
width: 100%;
height: auto;
}
```
**임의의 두 색으로 만드는 공식**
```
그림자색 #S_R S_G S_B, 하이라이트색 #H_R H_G H_B (각 0~255)
feFuncR tableValues = "S_R/255 H_R/255"
feFuncG tableValues = "S_G/255 H_G/255"
feFuncB tableValues = "S_B/255 H_B/255"
```
```js
// 헬퍼: hex 2개 → 듀오톤 필터 문자열
function duotoneFilter(id, shadowHex, highlightHex) {
const toRGB = h => {
const n = parseInt(h.replace('#', ''), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const s = toRGB(shadowHex);
const h = toRGB(highlightHex);
const f = i => `${(s[i] / 255).toFixed(4)} ${(h[i] / 255).toFixed(4)}`;
return `
<filter id="${id}" color-interpolation-filters="sRGB">
<feColorMatrix type="matrix" values="
0.2126 0.7152 0.0722 0 0
0.2126 0.7152 0.0722 0 0
0.2126 0.7152 0.0722 0 0
0 0 0 1 0"/>
<feComponentTransfer>
<feFuncR type="table" tableValues="${f(0)}"/>
<feFuncG type="table" tableValues="${f(1)}"/>
<feFuncB type="table" tableValues="${f(2)}"/>
</feComponentTransfer>
</filter>`;
}
// duotoneFilter('brandDuo', '#12103A', '#FF6B35')
```
**트라이톤**`tableValues`에 값을 3개 넣는다 (그림자 / 중간톤 / 하이라이트).
```xml
<feComponentTransfer>
<feFuncR type="table" tableValues="0.05 0.55 0.98"/>
<feFuncG type="table" tableValues="0.10 0.20 0.35"/>
<feFuncB type="table" tableValues="0.35 0.45 0.10"/>
</feComponentTransfer>
```
**함정**
- **`color-interpolation-filters="sRGB"`가 없으면 색이 전부 어긋난다.** 이 레시피에서는 필수.
- 원본 대비가 낮은 사진은 듀오톤에서 뭉개진다. 앞에 `feComponentTransfer type="linear" slope="1.3" intercept="-0.15"`로 대비를 올려주면 좋다.
**성능: 상** — 픽셀당 단순 산술 2회. 이미지에 적용해도 부담 없다.
---
## 14. 포스터화 / 컬러 밴딩
**언제 쓰나** — 스크린프린트, 레트로 게임, 벡터 일러스트 느낌. 그라디언트를 계단으로 만든다.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="posterize" color-interpolation-filters="sRGB">
<feComponentTransfer>
<feFuncR type="discrete" tableValues="0 0.25 0.5 0.75 1"/>
<feFuncG type="discrete" tableValues="0 0.25 0.5 0.75 1"/>
<feFuncB type="discrete" tableValues="0 0.25 0.5 0.75 1"/>
</feComponentTransfer>
</filter>
</defs>
</svg>
```
```css
.poster { filter: url(#posterize); }
```
**파라미터 가이드**
| tableValues | 단계 수 | 룩 |
|---|---|---|
| `0 1` | 2 | 하드 임계값(흑백 스텐실) |
| `0 0.5 1` | 3 | 강한 실크스크린 |
| `0 0.25 0.5 0.75 1` | 5 | 표준 포스터화 |
| `0 0.14 0.28 0.42 0.57 0.71 0.85 1` | 8 | 미묘한 밴딩 |
**채널별로 다른 단계 수**를 주면 색이 이동하며 인쇄 오차 느낌이 난다:
```xml
<feFuncR type="discrete" tableValues="0 0.5 1"/>
<feFuncG type="discrete" tableValues="0 0.33 0.66 1"/>
<feFuncB type="discrete" tableValues="0 1"/>
```
**듀오톤과 결합** — 레시피 13의 그레이스케일 뒤에 `discrete`를 넣으면 **2색 스크린프린트**가 된다.
**성능: 상**
---
## 15. 디더 / 임계값 (하프톤 대체)
**언제 쓰나** — 1비트 룩, 레트로 맥/게임보이, 리소 하프톤 근사. 그라디언트를 점 패턴으로 분해한다.
**원리** — 그레이 노이즈를 소스에 **가산**한 뒤 2단계 `discrete`로 자른다. 노이즈가 임계선 주변을 흩뜨려 점 패턴이 생긴다.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="dither" x="0%" y="0%" width="100%" height="100%"
color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.75"
numOctaves="1" seed="9" result="n"/>
<!-- 노이즈를 불투명 그레이로 (알파 노이즈 제거) -->
<feColorMatrix in="n" type="matrix" result="gray"
values="0.33 0.33 0.33 0 0
0.33 0.33 0.33 0 0
0.33 0.33 0.33 0 0
0 0 0 0 1"/>
<!-- 소스 + (노이즈 × 0.5 0.25) -->
<feComposite in="SourceGraphic" in2="gray" operator="arithmetic"
k1="0" k2="1" k3="0.5" k4="-0.25" result="mixed"/>
<feComponentTransfer in="mixed">
<feFuncR type="discrete" tableValues="0 1"/>
<feFuncG type="discrete" tableValues="0 1"/>
<feFuncB type="discrete" tableValues="0 1"/>
</feComponentTransfer>
</filter>
</defs>
</svg>
```
```css
.dither {
width: 220px;
height: 150px;
background: linear-gradient(90deg, #000, #fff);
filter: url(#dither);
}
```
**파라미터 가이드**
| 조절 | 효과 |
|---|---|
| `k3` (0.5) | 노이즈 진폭 = 디더 밴드의 **폭**. 0.3(좁음) ~ 0.8(넓음) |
| `k4` (0.25) | 항상 `k3/2`로 두어 노이즈를 0 중심으로 만든다 |
| `baseFrequency` 0.5~0.9 | 점 크기 |
| `tableValues="0 0.5 1"` | 3단계 디더 |
**색 디더** — 임계 후 `feColorMatrix`로 2색 매핑하면 리소 잉크 느낌이 난다.
**성능: 중** — feTurbulence + 산술 합성. 정적이면 안전, 애니메이션은 피하라.
---
## 16. 리소그래프 인쇄
**언제 쓰나** — 잡지/포스터 감성, 인디 브랜드, 제한 팔레트 아트디렉션.
**리소의 3요소**: ① 제한된 스팟 컬러 ② `multiply`로 겹치는 잉크 ③ 판 어긋남(misregistration) + 종이 그레인.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="risoGrain" x="0%" y="0%" width="100%" height="100%"
color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.7"
numOctaves="2" stitchTiles="stitch" seed="11" result="n"/>
<!-- RGB는 흰색 상수, 알파만 노이즈 → 흰 바탕에 랜덤 투명도 -->
<feColorMatrix in="n" type="matrix"
values="0 0 0 0 1
0 0 0 0 1
0 0 0 0 1
0.4 0.4 0.4 0 -0.1"/>
</filter>
</defs>
</svg>
<div class="riso">
<i class="ink-a"></i>
<i class="ink-b"></i>
</div>
```
```css
.riso {
--misreg-x: 2px;
--misreg-y: 1.5px;
position: relative;
width: 230px;
height: 160px;
background: #f2ece0; /* 종이색 */
border-radius: 6px;
overflow: hidden;
isolation: isolate;
}
.riso i {
position: absolute;
display: block;
width: 110px;
height: 110px;
top: 25px;
border-radius: 50%;
mix-blend-mode: multiply; /* 잉크가 겹치면 어두워진다 */
}
.riso .ink-a {
left: 20px;
background: #ff4f39; /* Riso Fluorescent Pink 근사 */
transform: translate(calc(var(--misreg-x) * -1), calc(var(--misreg-y) * -1));
}
.riso .ink-b {
left: 90px;
background: #2f6bff; /* Riso Blue 근사 */
transform: translate(var(--misreg-x), var(--misreg-y));
}
/* 종이 그레인 */
.riso::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: #fff;
filter: url(#risoGrain);
mix-blend-mode: multiply;
opacity: 0.5;
}
```
**파라미터 가이드**
| 조절 | 값 |
|---|---|
| 판 어긋남 | `--misreg-x/y` 0.5~4px. 실제 리소는 최대 3mm까지 어긋난다 |
| 잉크 팔레트 | 형광핑크 `#FF48B0`, 블루 `#0078BF`, 옐로 `#FFE800`, 그린 `#00A95C` |
| 종이색 | `#F2ECE0`, `#EFE6D4`, `#FBF7F0` |
| 그레인 강도 | `::after``opacity` 0.25~0.6 |
| 그레인 굵기 | `baseFrequency` 0.5(굵음) ~ 0.9(미세) |
**함정**`mix-blend-mode`가 부모 밖으로 새지 않도록 컨테이너에 **`isolation: isolate`**를 반드시 걸어라.
**성능: 중** — 잉크 레이어는 순수 CSS라 가볍고, 그레인 필터가 유일한 비용이다. 그레인을 레시피 02(배경 data URI) 방식으로 바꾸면 **상**으로 올라간다.
---
## 17. 텍스트 아웃라인 (feMorphology)
**언제 쓰나** — 스티커 타이포, 코믹/포스터, 배경 위 텍스트 가독성 확보. CSS `-webkit-text-stroke`와 달리 **글자 두께를 깎지 않는다.**
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<!-- (A) 컬러 아웃라인 -->
<filter id="outline" x="-20%" y="-20%" width="140%" height="140%"
color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="4" result="thick"/>
<feFlood flood-color="#22d3ee" result="col"/>
<feComposite in="col" in2="thick" operator="in" result="ring"/>
<feMerge>
<feMergeNode in="ring"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- (B) 속 빈 아웃라인 -->
<filter id="knockout" x="-25%" y="-25%" width="150%" height="150%"
color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="3" result="thick"/>
<feComposite in="thick" in2="SourceAlpha" operator="out" result="ring"/>
<feFlood flood-color="#f472b6" result="col"/>
<feComposite in="col" in2="ring" operator="in"/>
</filter>
</defs>
</svg>
<h1 class="outlined">OUTLINE</h1>
<h1 class="hollow">HOLLOW</h1>
```
```css
.outlined { font-size: 44px; font-weight: 900; color: #111; filter: url(#outline); }
.hollow { font-size: 52px; font-weight: 900; color: #f472b6; filter: url(#knockout); }
```
**파라미터 가이드**
| 조절 | 값 |
|---|---|
| 선 두께 | `radius` 1~8. 8 초과는 모서리가 각지게 뭉개진다 |
| 이중 아웃라인 | `feMorphology`를 두 번 체인 (radius 6 → 3), 색 두 개 |
| 굵은 글씨 | `operator="dilate"``SourceGraphic`에 걸면 원본 색 그대로 굵어진다 |
**이중 아웃라인 전체 코드**
```xml
<filter id="doubleOutline" x="-30%" y="-30%" width="160%" height="160%"
color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="8" result="outer"/>
<feFlood flood-color="#0f172a" result="outerCol"/>
<feComposite in="outerCol" in2="outer" operator="in" result="outerRing"/>
<feMorphology in="SourceAlpha" operator="dilate" radius="4" result="inner"/>
<feFlood flood-color="#fbbf24" result="innerCol"/>
<feComposite in="innerCol" in2="inner" operator="in" result="innerRing"/>
<feMerge>
<feMergeNode in="outerRing"/>
<feMergeNode in="innerRing"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
```
**성능: 상** — 텍스트는 선택·검색·스크린리더 접근이 그대로 유지된다.
---
## 18. 스티커
**언제 쓰나** — 배지, 이모지 스타일 라벨, 컷아웃 요소. 흰 테두리 + 아래로 떨어지는 그림자.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="sticker" x="-30%" y="-30%" width="160%" height="160%"
color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="8" result="outer"/>
<feFlood flood-color="#ffffff" result="white"/>
<feComposite in="white" in2="outer" operator="in" result="whiteRing"/>
<feDropShadow in="whiteRing" dx="0" dy="4" stdDeviation="4"
flood-color="#000000" flood-opacity="0.35" result="shadowed"/>
<feMerge>
<feMergeNode in="shadowed"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
</svg>
<span class="sticker">STICKER</span>
```
```css
.sticker {
font-size: 44px;
font-weight: 900;
color: #111;
filter: url(#sticker);
}
```
**파라미터 가이드**
| 조절 | 값 |
|---|---|
| 테두리 두께 | `radius` 4(얇음) ~ 14(두꺼움) |
| 떠 있는 높이 | `dy` 2~8, `stdDeviation` 2~8 |
| 그림자 세기 | `flood-opacity` 0.2~0.5 |
| 테두리 색 | `flood-color` — 흰색 대신 배경보다 밝은 색도 좋다 |
**이미지에도 적용 가능** — 투명 PNG 컷아웃에 그대로 걸면 스티커가 된다.
**성능: 상**
---
## 19. 네온 글로우
**언제 쓰나** — 다크 UI의 강조, 사이버펑크, 라이브/온에어 표시.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="neon" x="-40%" y="-40%" width="180%" height="180%"
color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="0" stdDeviation="2"
flood-color="#22d3ee" flood-opacity="1" result="s1"/>
<feDropShadow in="s1" dx="0" dy="0" stdDeviation="6"
flood-color="#7c3aed" flood-opacity="0.9" result="s2"/>
<feDropShadow in="s2" dx="0" dy="0" stdDeviation="14"
flood-color="#ec4899" flood-opacity="0.7"/>
</filter>
</defs>
</svg>
<h1 class="neon">NEON</h1>
```
```css
.neon {
font-size: 40px;
font-weight: 900;
color: #fff;
filter: url(#neon);
}
```
**애니메이션 글로우 링**`flood-color`는 CSS/SMIL 애니메이션이 가능하다.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="pulseRing" x="-20%" y="-20%" width="140%" height="140%"
color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="3" result="thick"/>
<feComposite in="thick" in2="SourceAlpha" operator="out" result="ring"/>
<feFlood flood-color="#22d3ee" result="col">
<animate attributeName="flood-color"
values="#22d3ee;#a855f7;#f43f5e;#22d3ee"
dur="4s" repeatCount="indefinite"/>
</feFlood>
<feComposite in="col" in2="ring" operator="in" result="glowRing"/>
<feGaussianBlur in="glowRing" stdDeviation="4" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="glowRing"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
</svg>
```
```css
.pulse-card {
width: 200px;
height: 130px;
background: #111827;
border-radius: 12px;
filter: url(#pulseRing);
}
@media (prefers-reduced-motion: reduce) {
.pulse-card { filter: none; box-shadow: 0 0 0 3px #22d3ee; }
}
```
**성능: 중**`stdDeviation="14"` 블러가 주 비용. 애니메이션은 `flood-color`만 바꾸므로(형태 재계산 없음) 비교적 저렴하다.
---
## 20. 스포트라이트
**언제 쓰나** — 히어로 포커스, 카드 호버 강조, 어두운 씬의 연출.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<!-- (A) 빛을 더하는 방식 -->
<filter id="spotAdd" x="-20%" y="-20%" width="140%" height="140%"
color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceAlpha" stdDeviation="5" result="bump"/>
<feDiffuseLighting in="bump" surfaceScale="5" diffuseConstant="1"
lighting-color="#ffd88a" result="light">
<feSpotLight id="spotSource"
x="55" y="15" z="90"
pointsAtX="110" pointsAtY="80" pointsAtZ="0"
specularExponent="6" limitingConeAngle="42"/>
</feDiffuseLighting>
<feComposite in="light" in2="SourceAlpha" operator="in" result="lightClipped"/>
<feBlend in="SourceGraphic" in2="lightClipped" mode="screen"/>
</filter>
<!-- (B) 음영을 만드는 방식(비네트) -->
<filter id="spotShade" x="-20%" y="-20%" width="140%" height="140%"
color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceAlpha" stdDeviation="5" result="bump"/>
<feDiffuseLighting in="bump" surfaceScale="5" diffuseConstant="1.6"
lighting-color="#ffffff" result="light">
<feSpotLight x="55" y="15" z="90"
pointsAtX="110" pointsAtY="80" pointsAtZ="0"
specularExponent="4" limitingConeAngle="55"/>
</feDiffuseLighting>
<feComposite in="light" in2="SourceAlpha" operator="in" result="lightClipped"/>
<feBlend in="SourceGraphic" in2="lightClipped" mode="multiply"/>
</filter>
</defs>
</svg>
<div class="spot-card">Focus</div>
```
```css
.spot-card {
width: 220px;
height: 150px;
background: #334155;
border-radius: 14px;
filter: url(#spotAdd);
}
```
**포인터 추적 스포트라이트**
```js
(function trackSpotlight() {
const card = document.querySelector('.spot-card');
const light = document.getElementById('spotSource');
if (!card || !light) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let raf = null;
let pending = null;
function apply() {
raf = null;
if (!pending) return;
light.setAttribute('x', String(pending.x));
light.setAttribute('y', String(pending.y));
light.setAttribute('pointsAtX', String(pending.x));
light.setAttribute('pointsAtY', String(pending.y));
pending = null;
}
card.addEventListener('pointermove', e => {
const r = card.getBoundingClientRect();
pending = { x: Math.round(e.clientX - r.left), y: Math.round(e.clientY - r.top) };
if (raf === null) raf = requestAnimationFrame(apply);
});
card.addEventListener('pointerleave', () => {
pending = { x: 55, y: 15 };
if (raf === null) raf = requestAnimationFrame(apply);
});
})();
```
> 검증됨: `setAttribute`로 `feSpotLight`의 `x/y/pointsAtX/pointsAtY`를 바꾸면 조명 위치가 즉시 갱신된다(광원을 좌상단→우하단으로 옮겨 빛 웅덩이가 따라 이동함을 확인). 포인터 이벤트 결선 자체는 표준 DOM API라 별도 위험이 없다.
**파라미터 가이드**
| 조절 | 효과 |
|---|---|
| `limitingConeAngle` 20(좁은 핀조명) ~ 70(넓은 조명) | 원뿔 반각 |
| `specularExponent` 1(균일) ~ 20(중심 집중) | 감쇠 |
| `z` 50(가까움/강함) ~ 200(멀음/균일) | 광원 거리 |
| `lighting-color` | 조명 색 — CSS 애니메이션 가능 |
| `mode="screen"` vs `"multiply"` | 밝히기 vs 어둡게(비네트) |
**함정**`feComposite operator="in"` + `SourceAlpha` 클리핑을 빼면 필터 영역 전체가 불투명 조명 사각형이 된다.
**성능: 중** — 조명 연산은 픽셀당 노멀 계산이 들어가 블러보다 비싸다. 포인터 추적은 rAF로 throttle 필수.
---
## 21. 광택 / 엠보스
**언제 쓰나** — 3D 느낌 버튼, 스큐어모픽 요소, 젤리/캔디 UI.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="glossy" x="-20%" y="-20%" width="140%" height="140%"
color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceAlpha" stdDeviation="6" result="bump"/>
<feSpecularLighting in="bump" surfaceScale="6" specularConstant="1"
specularExponent="25" lighting-color="#ffffff" result="spec">
<fePointLight x="60" y="20" z="120"/>
</feSpecularLighting>
<feComposite in="spec" in2="SourceAlpha" operator="in" result="specClip"/>
<feComposite in="SourceGraphic" in2="specClip" operator="arithmetic"
k1="0" k2="1" k3="1" k4="0"/>
</filter>
</defs>
</svg>
<button class="glossy-btn">Press</button>
```
```css
.glossy-btn {
width: 200px;
height: 140px;
background: #3b82f6;
border: 0;
border-radius: 20px;
color: transparent; /* 텍스트는 필터 밖 레이어에 두는 것을 권장 */
filter: url(#glossy);
cursor: pointer;
}
```
**파라미터 가이드**
| 조절 | 효과 |
|---|---|
| `stdDeviation`(범프 블러) 3~10 | 클수록 모서리가 둥근 3D |
| `surfaceScale` 2~10 | 돌출 높이 |
| `specularExponent` 5(넓은 광택) ~ 60(핀 하이라이트) | 광택 집중도 |
| `fePointLight z` 60(가까움) ~ 250(멀음) | 하이라이트 크기 |
| `specularConstant` 0.5~2 | 밝기 |
**엠보스 변형** (`feConvolveMatrix` — 돌/콘크리트 각인 질감)
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="embossTexture" x="0%" y="0%" width="100%" height="100%"
color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.06"
numOctaves="3" seed="4" result="n"/>
<!-- 노이즈를 불투명 그레이로 -->
<feColorMatrix in="n" type="matrix" result="g"
values="0.33 0.33 0.33 0 0
0.33 0.33 0.33 0 0
0.33 0.33 0.33 0 0
0 0 0 0 1"/>
<!-- 합이 0인 커널 + bias 0.5 = 중간 회색 기준 엠보스 -->
<feConvolveMatrix in="g" order="3" preserveAlpha="true" divisor="1" bias="0.5"
kernelMatrix="-2 -1 0
-1 0 1
0 1 2" result="emb"/>
<feComposite in="emb" in2="SourceAlpha" operator="in"/>
</filter>
</defs>
</svg>
```
```css
.stone { width: 180px; height: 130px; background: #8b8b8b;
border-radius: 12px; filter: url(#embossTexture); }
```
> 검증됨. **커널의 합이 결과를 좌우한다**는 점을 기억하라.
> - 합 = 1 (`-2 -1 0 / -1 1 1 / 0 1 2`), `bias="0"` → 원본 밝기를 유지하며 엣지만 강조. 부드러운 그라디언트에는 거의 변화가 없다.
> - 합 = 0 (`-2 -1 0 / -1 0 1 / 0 1 2`), `bias="0.5"` → 엣지가 없는 곳은 **중간 회색**, 엣지에서만 밝고 어두워진다. **입력에 텍스처(노이즈)가 있어야 의미가 있다.**
> - 밝은 소스에 합=1 커널 + `bias="0.5"`를 쓰면 값이 1을 넘어 **전체가 흰색으로 날아간다**(실제로 재현함).
**샤픈** (변위 후 흐려진 엣지 복구용)
```xml
<feConvolveMatrix order="3" preserveAlpha="true"
kernelMatrix="0 -1 0 -1 5 -1 0 -1 0"/>
```
**함정** — 필터를 건 요소 안의 텍스트도 조명 처리되어 뿌옇게 된다. 텍스트는 별도 레이어로 얹어라.
**성능: 중**
---
## 22. 글리치 밴드
**언제 쓰나** — 로딩 실패 상태, 사이버펑크 전환 효과, 에러 화면. **순간적으로만** 쓴다.
**원리** — 대부분이 중립색(127,127)인 평면 위에, 얇은 가로 밴드만 다른 색으로 칠한 이미지를 변위맵으로 쓴다. 밴드가 지나가는 줄만 가로로 밀린다.
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="glitch" x="-10%" y="-10%" width="120%" height="120%"
primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<!-- 전체를 중립으로 채움: 변위 0 -->
<feFlood flood-color="rgb(127,127,127)" x="0" y="0" width="100%" height="100%"
result="neutral"/>
<!-- 밴드 1 -->
<feFlood flood-color="rgb(200,127,127)" x="0" y="0" width="100%" height="14"
result="band1">
<animate attributeName="y" dur="2.7s"
values="0;40;12;90;30;120;0" repeatCount="indefinite"/>
</feFlood>
<!-- 밴드 2 -->
<feFlood flood-color="rgb(60,127,127)" x="0" y="0" width="100%" height="8"
result="band2">
<animate attributeName="y" dur="1.9s"
values="120;20;100;55;10;80;120" repeatCount="indefinite"/>
</feFlood>
<feMerge result="map">
<feMergeNode in="neutral"/>
<feMergeNode in="band1"/>
<feMergeNode in="band2"/>
</feMerge>
<feDisplacementMap in="SourceGraphic" in2="map" scale="40"
xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs>
</svg>
<div class="glitch-box">SYSTEM ERROR</div>
```
```css
.glitch-box {
width: 260px;
height: 140px;
display: grid;
place-items: center;
background: #111;
color: #f43f5e;
font-weight: 800;
letter-spacing: 0.1em;
filter: url(#glitch);
}
@media (prefers-reduced-motion: reduce) {
.glitch-box { filter: none; }
}
```
> 검증됨: 밴드가 지나가는 줄만 가로로 밀리는 글리치가 정상 렌더된다. 밴드가 안 보이면 `primitiveUnits="userSpaceOnUse"`가 빠졌는지, `height`가 % 단위로 잘못 들어갔는지 먼저 확인하라.
**파라미터 가이드**
| 조절 | 효과 |
|---|---|
| 밴드 색의 R값 | 127에서 멀수록 그 줄이 크게 밀린다 (0~255) |
| `scale` | 전체 밀림 세기 (20~60) |
| 밴드 `height` | 밀리는 줄의 두께 |
| `dur` 값을 서로 소수로 | 반복 주기가 길어져 자연스러움 |
**성능: 하** — SMIL이 매 프레임 맵을 재구성하고 변위를 다시 계산한다. **상시 재생 금지.** 상태 전환 시 0.5~2초만 켜라.
---
## 부록 A. 필터 조합 시 순서 원칙
1. **소스 생성** (`feTurbulence`, `feFlood`, `feImage`) — 재료를 먼저 만든다
2. **기하 변형** (`feDisplacementMap`, `feMorphology`, `feOffset`) — 형태를 바꾼다
3. **블러** (`feGaussianBlur`) — 부드럽게 한다
4. **색/알파 조작** (`feColorMatrix`, `feComponentTransfer`) — 대비·색을 잡는다
5. **합성** (`feComposite`, `feBlend`, `feMerge`) — 원본과 합친다
순서를 바꾸면 결과가 달라진다. 예: 블러 → 알파대비 = gooey(붙는다) / 알파대비 → 블러 = 그냥 흐린 도형.
## 부록 B. 어디에 텍스트를 두는가
필터를 거는 요소 **안**에 텍스트를 두면 텍스트도 필터를 통과한다. 대부분의 레시피에서 이건 버그다.
```html
<!-- ❌ 나쁨: 텍스트가 뭉개진다 -->
<div class="gooey-card">텍스트</div>
<!-- ✅ 좋음: 표면 레이어와 콘텐츠 레이어 분리 -->
<div class="card">
<div class="card-surface"></div> <!-- filter: url(#...) -->
<div class="card-content">텍스트</div> <!-- 필터 없음 -->
</div>
```
```css
.card { position: relative; }
.card-surface { position: absolute; inset: 0; filter: url(#effect); }
.card-content { position: relative; }
```
예외: 레시피 08(색수차), 09(러프), 10(잉크), 12(물결), 17(아웃라인), 18(스티커), 19(네온)은 **텍스트에 거는 것이 목적**이다.
## 부록 C. 레시피 선택 트리
```
질감을 더하고 싶다
├─ 정적 배경 위 → 02. 필름 그레인 (성능 최우선)
├─ 그라디언트 밴딩 제거 → 03. 그레이니 그라디언트
└─ 물리적 요철 → 04. 종이 질감
배경을 왜곡하고 싶다
├─ 굴절이 꼭 필요 + Chromium 타겟 → 05 / 06
└─ 크로스브라우저 필수 → 07. 프로스티드 글래스
형태를 유기적으로 만들고 싶다
├─ 도형이 서로 융합 → 01. Gooey
├─ 손으로 그린 느낌 → 09 (정적) / 11 (애니메이션)
└─ 액체처럼 흐름 → 12. 물결
색을 통제하고 싶다
├─ 브랜드 2색으로 통일 → 13. 듀오톤
├─ 인쇄물 느낌 → 14 / 15 / 16
└─ 색 분리 효과 → 08. 색수차
빛을 다루고 싶다
├─ 포커스 연출 → 20. 스포트라이트
└─ 입체감 → 21. 광택/엠보스
텍스트를 강조하고 싶다
├─ 배경과 분리 → 17. 아웃라인
├─ 컷아웃 느낌 → 18. 스티커
└─ 발광 → 19. 네온
```