CSS Paint API: `registerPaint()`로 JavaScript Canvas를 CSS `background-image`로 연결하기
복잡한 배경 패턴을 구현할 때 어떻게 하셨나요? SVG 파일 만들고, PNG export하고, 다크 모드 전환 때 이미지 통째로 교체하다 보면 문득 이런 생각이 듭니다. "배경 이미지를 코드로 직접 그릴 수는 없을까?" 저도 처음 CSS Houdini를 검색했을 때 '이게 뭐지' 싶었는데, 문서가 워낙 방대하고 W3C 스펙 링크들이 잔뜩 나와서 덮어버렸던 기억이 납니다. 사실 필요한 부분은 꽤 작습니다.
CSS Houdini 중에서 CSS Paint API만 놓고 보면 개념은 단순합니다. 브라우저가 CSS 이미지를 그려야 할 시점에 "이 부분은 내가 짠 JavaScript 함수로 그릴게"라고 끼어들 수 있게 해주는 API입니다. registerPaint()로 커스텀 Paint Worklet을 등록하는 법부터 CSS 변수와 연동해 애니메이션까지 구현하는 실전 패턴까지 이 글에서 다룹니다. 핵심은 이미지 에셋 없이, 순수 Canvas API로 CSS background-image를 동적으로 그릴 수 있다는 것입니다.
Canvas를 처음 접하신다면 MDN의 Canvas 튜토리얼을 먼저 훑어보고 오시는 걸 권장합니다. 그렇지 않은 분이라면, 이 글을 읽고 나면 hover 한 번으로 Ripple 애니메이션을 JS 한 줄 없이 만들 수 있게 됩니다.
핵심 개념
CSS Paint API가 렌더링 파이프라인에 끼어드는 방식
브라우저는 페이지를 렌더링할 때 레이아웃 → 페인트 → 합성 단계를 거칩니다. 기존에는 개발자가 CSS로 선언하면 브라우저가 알아서 페인트했죠. CSS Paint API는 이 페인트 단계에서 "이미지가 필요한 속성"에 한해 우리가 구현한 함수를 대신 실행할 수 있게 해줍니다.
/* 브라우저는 'checkerboard'라는 이름의 Worklet을 호출해 이미지를 그립니다 */
.element {
background-image: paint(checkerboard);
}background-image, border-image, mask-image처럼 이미지 값을 받는 CSS 속성이라면 어디서나 paint() 함수를 쓸 수 있습니다. 파일 경로 대신 함수 이름을 넣는 느낌이라고 생각하면 직관적으로 이해가 됩니다.
Worklet이란? Web Worker와 비슷한 경량 실행 컨텍스트입니다. Paint Worklet은 메인 스레드를 차단하지 않도록 설계되어 있어 복잡한 패턴을 그려도 UI가 버벅이지 않습니다. 다만 스레딩 모델은 구현체마다 다르며, "메인 스레드 부하가 전혀 없다"고 단정할 수는 없습니다.
registerPaint() 클래스 작성부터 CSS 호출까지 — 3단계로 이해하기
Paint Worklet을 처음 만들면 "파일을 왜 따로 분리해야 하지?", "등록은 어디서 하지?"를 헷갈리기 쉽습니다. 전체 흐름을 단계별로 정리해보면 이렇습니다.
1단계: Worklet 파일 작성
반드시 별도 JS 파일로 분리해야 합니다. 이 파일은 addModule()로 로드될 때 Worklet 실행 컨텍스트에서 돌아가기 때문입니다.
// my-painter.js (별도 파일로 분리 필수)
registerPaint('my-painter', class {
// 추적할 CSS 변수 선언. 이 목록의 값이 바뀔 때만 paint()가 재호출됩니다
static get inputProperties() {
return ['--my-color', '--my-size'];
}
// 실제 그리기 로직
paint(ctx, geometry, properties) {
const color = properties.get('--my-color').toString();
const size = parseInt(properties.get('--my-size')) || 20;
ctx.fillStyle = color;
ctx.fillRect(0, 0, geometry.width, geometry.height);
}
// (선택) CSS paint() 함수에 직접 인자를 전달하고 싶을 때
// CSS에서: background-image: paint(my-painter, 10px, red);
static get inputArguments() {
return ['<length>', '<color>'];
}
});| 메서드 | 역할 |
|---|---|
inputProperties() |
추적할 CSS 프로퍼티 선언. 목록에 없는 변수가 바뀌어도 paint()는 재호출되지 않음 |
paint(ctx, geometry, properties) |
실제 그리기 함수. Canvas API와 거의 동일 |
inputArguments() |
paint() CSS 함수에 직접 전달할 인자 타입 정의 (선택) |
ctx는 PaintRenderingContext2D인데, CanvasRenderingContext2D와 거의 같습니다. 다만 getImageData() 같은 픽셀 조작 API와 텍스트 렌더링 API는 지원하지 않습니다. 도형, 경로, 그라디언트 중심의 그리기라면 실무에서 불편함을 느끼기 어렵습니다.
2단계: 메인 스크립트에서 등록
// main.js
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('my-painter.js');
}addModule()은 비동기로 모듈을 로드합니다. 초기 렌더링 시 잠깐 배경이 비어 보일 수 있으니 폴백 스타일을 함께 선언해두는 게 좋습니다.
3단계: CSS에서 호출
.card {
/* 폴백: Paint API 미지원 브라우저용 */
background-color: #6c63ff;
/* 지원 브라우저에서는 Worklet이 그림 */
--my-color: #6c63ff;
--my-size: 30;
background-image: paint(my-painter);
}Progressive Enhancement 원칙:
if ('paintWorklet' in CSS)체크와 CSS 폴백 선언을 함께 쓰면, 미지원 브라우저에서도 기본 스타일이 정상 동작합니다. 이 패턴이 실무에서 가장 안전한 적용 방식입니다.
실전 적용
예시 1: CSS 변수로 제어하는 체커보드 패턴
가장 직관적인 예시입니다. CSS 변수 세 개로 완전히 동적인 체크 패턴을 만들 수 있습니다.
처음 보면 한 가지가 의아할 수 있습니다. --cell-size: 24처럼 단위 없는 숫자를 CSS 변수에 넣는 이유가 뭐냐는 건데요. CSS 커스텀 프로퍼티는 기본적으로 값을 문자열로 저장합니다. props.get('--cell-size').toString()을 호출하면 "24"라는 문자열이 나오고, parseInt()로 정수로 변환해 루프에서 씁니다. 24px로 저장하면 단위를 직접 벗겨내야 해서 더 번거로워집니다. 그래서 단위 없는 숫자를 쓰는 게 관용구처럼 굳어져 있습니다.
앞서 설명한 등록 방식(CSS.paintWorklet.addModule('checkerboard.js'))과 동일하게 등록한 후 아래 코드를 사용합니다.
// checkerboard.js
registerPaint('checkerboard', class {
static get inputProperties() {
return ['--cell-size', '--color-a', '--color-b'];
}
paint(ctx, { width, height }, props) {
const size = parseInt(props.get('--cell-size')) || 20;
const colorA = props.get('--color-a').toString() || '#ffffff';
const colorB = props.get('--color-b').toString() || '#000000';
for (let y = 0; y < height; y += size) {
for (let x = 0; x < width; x += size) {
const isEven = (Math.floor(x / size) + Math.floor(y / size)) % 2 === 0;
ctx.fillStyle = isEven ? colorA : colorB;
ctx.fillRect(x, y, size, size);
}
}
}
});.pattern-bg {
--cell-size: 24;
--color-a: #f0f4ff;
--color-b: #c7d2fe;
background-image: paint(checkerboard);
width: 100%;
height: 300px;
}
/* 다크 모드 전환 — 이미지 파일 교체 없음 */
@media (prefers-color-scheme: dark) {
.pattern-bg {
--color-a: #1e1b4b;
--color-b: #312e81;
}
}| 포인트 | 설명 |
|---|---|
geometry.width / height |
요소의 물리적 픽셀 크기를 받음. devicePixelRatio가 반영된 값이 전달되어 Retina 디스플레이에서도 별도 보정 없이 선명하게 표시됨 |
props.get('--cell-size') |
CSS Typed OM 객체로 반환. parseInt() 또는 .toString() 변환 후 사용 |
| CSS 변수 변경 | --color-a 값만 바꿔도 paint()가 즉시 재호출되어 리렌더링 |
예시 2: @property + transition으로 만드는 Ripple 애니메이션
솔직히 이 패턴이 CSS Paint API의 진짜 매력입니다. @property로 숫자형 커스텀 프로퍼티를 등록하면 CSS transition이 중간값을 자동으로 보간해줍니다. Paint Worklet이 그 중간값을 받아 매 프레임 재렌더링하면, JS 애니메이션 루프 없이 부드러운 효과가 만들어집니다.
앞서 설명한 방식으로 CSS.paintWorklet.addModule('ripple.js')를 등록한 후 아래 코드를 사용합니다.
// ripple.js
registerPaint('ripple', class {
static get inputProperties() {
return ['--ripple-progress', '--ripple-color'];
}
paint(ctx, { width, height }, props) {
const progress = parseFloat(props.get('--ripple-progress')) || 0;
const color = props.get('--ripple-color').toString() || '#6c63ff';
const centerX = width / 2;
const centerY = height / 2;
// 어느 방향으로 퍼져도 요소 모서리까지 닿는 최대 반지름
const maxRadius = Math.hypot(centerX, centerY);
const radius = maxRadius * progress;
ctx.fillStyle = '#f8fafc';
ctx.fillRect(0, 0, width, height);
if (progress > 0) {
// save/restore로 globalAlpha 상태를 안전하게 격리합니다
ctx.save();
ctx.globalAlpha = 1 - progress;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.restore();
}
}
});/* @property로 숫자형 타입 등록 — transition 보간에 필수 */
@property --ripple-progress {
syntax: '<number>';
initial-value: 0;
inherits: false;
}
.ripple-btn {
--ripple-progress: 0;
--ripple-color: rgba(108, 99, 255, 0.4);
background-color: #f8fafc; /* 폴백 */
background-image: paint(ripple);
transition: --ripple-progress 0.6s ease-out;
}
.ripple-btn:hover {
--ripple-progress: 1;
}@property가 없으면 CSS는 --ripple-progress를 그냥 문자열로 취급해서 중간값 보간이 일어나지 않습니다. @property로 <number> 타입을 선언해야 0 → 0.1 → 0.2 ... → 1 흐름이 생기고, 그 흐름을 Paint Worklet이 받아서 시각화하는 구조입니다.
한 가지 더 짚고 싶은 건 ctx.save()/ctx.restore() 패턴입니다. globalAlpha처럼 Canvas의 전역 상태를 직접 조작한 뒤 수동으로 되돌리는 방식은 나중에 코드가 복잡해질수록 상태 관리가 꼬이기 쉽습니다. Canvas API 관용구대로 save()/restore()로 상태를 격리하는 습관을 들여두면 훨씬 안전합니다.
예시 3: 스켈레톤 로딩 shimmer 효과
--shimmer-progress를 @keyframes로 구동하는 패턴인데, Ripple 예시에서 설명한 것과 같은 이유로 @property 선언이 필요합니다. @keyframes로 커스텀 프로퍼티 값을 직접 조작하려면 CSS가 타입을 알아야 보간이 가능합니다.
앞서 설명한 방식으로 CSS.paintWorklet.addModule('skeleton-shimmer.js')를 등록한 후 아래 코드를 사용합니다.
// skeleton-shimmer.js
registerPaint('skeleton-shimmer', class {
static get inputProperties() {
return ['--shimmer-progress'];
}
paint(ctx, { width, height }, props) {
const progress = parseFloat(props.get('--shimmer-progress')) || 0;
ctx.fillStyle = '#e2e8f0';
ctx.fillRect(0, 0, width, height);
const shimmerX = (progress * (width + 200)) - 100;
const gradient = ctx.createLinearGradient(shimmerX - 100, 0, shimmerX + 100, 0);
gradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.6)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
}
});/* Ripple 예시와 같은 이유로 @property 선언이 필요합니다 */
@property --shimmer-progress {
syntax: '<number>';
initial-value: 0;
inherits: false;
}
.skeleton {
--shimmer-progress: 0;
background-color: #e2e8f0; /* 폴백 */
background-image: paint(skeleton-shimmer);
border-radius: 8px;
height: 20px;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
from { --shimmer-progress: 0; }
to { --shimmer-progress: 1; }
}기존에 linear-gradient와 background-size, background-position 조합으로 구현하던 shimmer 효과가 훨씬 직관적인 코드로 바뀝니다. 요소 크기가 달라져도 항상 정확하게 맞게 그려지는 것도 실무에서 꽤 편리한 부분입니다.
장단점 분석
장점
가장 차별적인 장점 두 가지를 먼저 꼽자면, 해상도 독립성과 @property와의 조합입니다. 벡터 방식으로 그려지므로 Retina·HiDPI 디스플레이에서 별도 대응 없이 선명하게 표시되고, @property + transition/@keyframes로 JS 없는 순수 CSS 애니메이션이 가능합니다. 이 두 가지가 이미지 에셋이나 JS Canvas 대비 이 API를 써야 할 결정적인 이유입니다.
| 항목 | 내용 |
|---|---|
| 해상도 독립 | Retina·HiDPI 디스플레이에서 자동으로 선명하게 표시 |
| 메인 스레드 설계 | 복잡한 패턴을 그려도 UI 반응성에 영향을 최소화 |
| CSS 변수 반응성 | inputProperties 목록의 값이 변경될 때만 paint() 재호출 — 불필요한 재렌더링 없음 |
| CSS 애니메이션 연동 | @property + transition/@keyframes로 JS 없이 부드러운 애니메이션 구현 가능 |
| 이미지 에셋 절감 | 배경용 PNG/SVG 파일 없이 코드로 완전 대체 — 다크 모드 테마 전환이 변수 값 변경으로 해결 |
| 재사용성 | 한 번 등록한 Worklet을 모든 요소에서 CSS 변수만 달리해 재사용 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Firefox 미지원 | 2025년 현재 동작하지 않음. 전 세계 시장 점유율 약 3~4% 수준으로, 소비자 서비스라면 무시하기 어렵고 개발자 도구·내부 대시보드라면 감수 가능 범위 | CSS 폴백 선언 + if ('paintWorklet' in CSS) 조건부 적용 |
| Safari 부분 지원 | 완전한 지원이 아니며 동작 차이 발생 가능 | 동작 확인 후 적용 범위 결정 |
| Canvas API 하위셋 | getImageData(), 텍스트 렌더링 API 미지원 |
도형·경로·그라디언트 중심으로 설계 |
| 폴리필 한계 | GoogleChromeLabs의 css-paint-polyfill이 있으나 슈도 엘리먼트 미지원, Firefox에서 불안정 |
폴리필보다 CSS 폴백이 더 안정적인 경우가 많음 |
| 별도 파일 필수 | Worklet은 반드시 별도 JS 파일로 분리해야 함 | Webpack이라면 worklet-loader, Vite라면 ?url 접미사 활용 |
| 디버깅 불편 | DevTools에서 Worklet 내부 중단점 설정이 일반 JS보다 까다로움 | console.log 대신 렌더링 결과로 직접 확인하는 습관 필요 |
실무에서 가장 흔한 실수
-
@property없이 transition을 기대하는 경우 — 저도 처음 Ripple 예시를 직접 써보다가 "왜 hover해도 아무것도 안 일어나지?"를 한참 디버깅했는데, 범인은 여기였습니다. CSS 커스텀 프로퍼티는 기본적으로 문자열로 취급되어 중간값 보간이 되지 않습니다. 숫자 애니메이션이 필요하다면 반드시@property로 타입을 선언해야 합니다. -
inputProperties에 추적할 변수를 빠뜨리는 경우 — 이 목록에 없는 CSS 변수는 변경되어도paint()가 호출되지 않습니다. "분명히 변수를 바꿨는데 배경이 왜 안 바뀌지?"라는 의문이 든다면 이 목록부터 확인해보시면 좋습니다. -
Worklet 파일을 번들러가 인라인으로 처리하는 경우 — Webpack, Vite 등은 기본적으로 JS 파일을 인라인·청크 분할합니다. Worklet 파일은 URL로
addModule()에 전달해야 하므로 별도 처리가 필요합니다. Vite라면import workletUrl from './my-painter.js?url'→CSS.paintWorklet.addModule(workletUrl)패턴이 깔끔합니다.
마치며
이 API를 써보고 나서 느낀 건, CSS와 Canvas의 경계가 생각보다 훨씬 가깝다는 겁니다. background-image라는 CSS 속성 하나가 Canvas 드로잉 컨텍스트와 직접 연결될 수 있다는 게 처음엔 어색하게 느껴지지만, 막상 쓰고 나면 이게 왜 이제서야 나왔나 싶은 자연스러운 연장선입니다. @property와 결합한 CSS 애니메이션 패턴은 특히 2024~2025년 가장 주목받는 활용 방식으로, 브라우저 지원 현황을 고려하면 사이드 프로젝트나 내부 도구에서 먼저 경험해보시는 걸 권장합니다.
지금 바로 시작해볼 수 있는 3단계:
- houdini.how에서 커뮤니티가 만들어놓은 Worklet을 먼저 가져다 써보시면 좋습니다. npm 패키지로 배포된 것들이 많아
addModule()호출 한 번으로 바로 쓸 수 있고, 내부 코드를 읽어보면 구조가 금방 눈에 들어옵니다. - 체커보드 예시처럼 단순한 패턴 하나를 직접 손으로 작성해보면,
registerPaint()→addModule()→ CSSpaint()세 줄짜리 흐름이 자연스럽게 몸에 익습니다. 이 흐름을 한 번 통과하면 이후 복잡한 구현도 무리 없이 따라옵니다. @property로 숫자형 변수를 등록하고 CSS transition을 붙여보시면 됩니다. hover 시 값이 0에서 1로 바뀌는 것만으로도 Ripple, Shimmer, 프로그레스 배경 등 다양한 효과가 가능하고, 이 패턴이 손에 익으면 활용 폭이 크게 넓어집니다.
참고 자료
- CSS Painting API | MDN Web Docs
- Using the CSS Painting API | MDN Guide
- CSS Paint API | Chrome for Developers Blog
- CSS Painting API Level 1 | W3C Spec
- The CSS Paint API | CSS-Tricks
- Creating Generative Patterns with The CSS Paint API | CSS-Tricks
- Cross-browser paint worklets and Houdini.how | web.dev
- Drawing Graphics with the CSS Paint API | Codrops
- css-paint-polyfill | GoogleChromeLabs
- awesome-css-houdini | CSSHoudini
- Is Houdini Ready Yet?
- Programmatically create images with the CSS Paint API | SitePen