여섯 장의 정사각 표지마다 이웃한 네 색덩어리가 번지고, 그 위로 1물리 픽셀 크기의 회색 입자와 흰 세선 문양이 겹친다. 6장 정사각 표지 월
Indie BuildersWeekly
AI WorkflowHandbook
DesignNotes
Late NightShipping
Build inPublic
컴퓨터로만 계산한 순수 그라디언트는 표면이 매끈해 플라스틱처럼 보이기 쉽고, 화면에 따라 색이 계단처럼 끊기는 현상이 나타난다. 흐릿하게 번진 색덩어리 위로 물리 픽셀 크기의 회색 입자를 한 겹 덮자 색계단이 흩어지면서 인화지나 필름 사진 같은 질감이 살아났다. 카드 위로 포인터가 들어오면 색덩어리와 세선 문양이 서로 반대 방향으로 비껴나고 커서 둘레에 은은한 빛이 번진다.
위쪽은 밝고 차갑게, 아래쪽은 어둡고 따뜻하게 배색하고 색상환에서 이웃한 색만 섞어야 맑은 중간색을 유지할 수 있다. 4개 카드당 방사형 색덩어리
바탕에는 위에서 아래로 흐르는 수직 선형 그라디언트를 먼저 적용한다. 하늘과 지면의 밝기 차이를 본떠 위쪽에는 밝고 차가운 색조를, 아래쪽에는 어둡고 따뜻한 색조를 배치했다. 그 위로 큰 방사형 색덩어리 네 개를 모서리와 외곽에 두고, 블러 필터를 적용해 가장자리가 부드럽게 번지도록 윤곽을 흐렸다.
색상환에서 정반대에 놓인 보색이 직접 만나면 두 색이 섞이는 중간 구간이 회갈색으로 탁해진다. 이를 피하려고 여섯 장 모두 색상환에서 서로 이웃한 색만 골라 조합했다. 회청색에서 모래빛 노랑을 거쳐 주황빛 붉은색으로 이어 가거나, 초록을 파랑이나 노랑과만 짝지어 카드마다 고유한 인접 배색을 만들었다.
| 카드 | 영문 표제 | 색조 흐름 | 인접 색상환 배색 (상단과 하단) | SVG 세선 문양 |
|---|---|---|---|---|
| 01 | Product Teardowns | 보라분홍 → 오렌지골드 | 상단#9BA5E0, #879BE8, #E09EC5 하단#E57445, #DF4D3C, #F7AC44 | 없음 (순수 그레인 대조군) |
| 02 | Indie Builders Weekly | 회청색 → 짙은 갈색 | 상단#7D97A7, #6B899C, #E8BE82 하단#341307, #C95224, #220902 | 물결 방사선 (36방향) |
| 03 | AI Workflow Handbook | 안개빛 파랑 → 심해 파랑 | 상단#C7D6E2, #D4E1EB, #7EB5A7 하단#0D2D3B, #225D72, #08202B | 동심 등고선 (15중 링) |
| 04 | Design Notes | 안개빛 파랑 → 먹색 초록 | 상단#BCCAD3, #C8D5DE, #9AA35C 하단#13260E, #496943, #0C1C08 | 수평 음파선 (19줄 사인파) |
| 05 | Late Night Shipping | 라벤더 → 심야 보라 | 상단#ABAED8, #9EA3D6, #EC8EBC 하단#1D0B2B, #4D2268, #150620 | 도트 매트릭스 (16×16 격자) |
| 06 | Build in Public | 스카이 그레이 → 녹슨 붉은색 | 상단#CCD6DF, #C4D0DB, #FAD1A7 하단#6B2313, #DE5538, #561A0D | 양볼록 렌즈 격자 (4×4 교차) |
기기 픽셀비에 맞춘 1픽셀 크기의 회색 입자는 색계단 현상을 줄이고, 중앙을 비운 두께 0.8픽셀의 흰 선은 글자가 잘 읽히도록 돕는다. 0.40 오버레이 입자 불투명도
오프스크린 Canvas가 그린 128×128 무작위 회색조 타일이 카드 전면을 바둑판처럼 덮는다. 기기 픽셀비(devicePixelRatio)로 128픽셀을 나눈 값(128 / dpr)을 타일 크기로 삼고 픽셀 보존 렌더링을 걸면, 고해상도 레티나 화면에서도 노이즈 입자 하나가 물리 픽셀 하나와 일대일로 맞물린다. 혼합 모드를 오버레이(overlay)로, 불투명도를 0.40으로 두어 밝은 영역에서는 입자가 선명하게 드러나고 어두운 영역에서는 조용히 가라앉게 했다.
첫 번째 대조군 카드를 뺀 다섯 장 위로 두께 0.8픽셀, 불투명도 0.30 안팎의 흰색 SVG 세선이 올라앉는다. 다섯 장이 나란히 물결 방사선, 동심 등고선, 수평 음파선, 도트 매트릭스, 양볼록 렌즈 격자를 한 종류씩 나눠 가졌다. 정중앙에는 방사형 마스크(radial-gradient mask)를 씌워 문양을 투명하게 비웠고, 덕분에 두 줄짜리 흰색 영문 표제가 선과 겹치지 않고 선명하게 읽힌다.
- 중앙 비움 구간 (투과율 0%)18%
- 점진적 감쇠 전이 구간34%
- 세선 문양 완전 노출 구간48%
- 영문 표제 폭36%
- 카드 외곽100%
색덩어리가 20초 이상 주기로 천천히 유영하다가 포인터가 들어오면 강성 70의 스프링 물리식을 따라 매끄럽게 방향을 바꾼다. 0.86 스프링 감쇠비
네 개의 색덩어리에는 CSS 부유 애니메이션을 적용했다. 주기를 21초에서 28초 사이로 각각 다르게 설정해 정지 상태에서도 생동감을 유지하도록 했다. 각 색덩어리는 독립된 궤적을 따라 미세하게 이동하고 크기를 바꾸며 끊임없이 색의 번짐을 재조합한다.
포인터가 카드 위에 올라오면 세 레이어가 서로 다른 비율로 움직인다. 색덩어리 묶음은 커서 방향으로 최대 28픽셀 끌려오고, SVG 세선 문양은 반대 방향으로 최대 14픽셀 밀려나며 깊이감을 만든다. 동시에 커서가 있는 지점에서 흰색 방사형 소프트 글로우가 서서히 밝아진다. 모든 이동과 복귀는 고정 시간 전환 대신 강성 70, 감쇠비 0.86의 2차 스프링 식을 매 프레임 적분해 구한다. 이동 도중에 커서가 갑자기 꺾이거나 카드 밖으로 빠져나가도 색덩어리와 세선 문양이 직전 속도를 품은 채 부드럽게 감속하는 까닭이다. 운영체제에서 모션 축소(prefers-reduced-motion)를 켜 두면 부유와 스프링 이동이 모두 멈추고 정적 표지만 남는다.
| 레이어 | 평상시 동작 | 포인터 호버 반응 | 최대 변위 및 강도 |
|---|---|---|---|
| 방사형 색덩어리 4개 | 21~28초 주기 저속 부유 및 미세 스케일 | 커서 방향으로 스프링 추적 이동 | +28px |
| SVG 세선 문양 | 정지 (중앙 방사형 마스크 유지) | 커서 반대 방향으로 역패럴랙스 이동 | -14px |
| 커서 소프트 글로우 | 불투명도 0 (비가시 상태) | 커서 좌표 추적 및 페이드인 | 불투명도 0.26 |
| Canvas 물리 픽셀 그레인 | 고정 타일 (1입자 = 1물리 픽셀) | 표면 질감 유지를 위해 좌표 고정 | 0px |
이 카드 월은 외부 이미지나 라이브러리 없이 단일 파일로 동작하며, 표지와 히어로 카드 제작에 필요한 다섯 가지 기준 수치를 제시한다. 0개 외부 이미지와 라이브러리
이 기법을 다섯 가지 제작 규약으로 정리하고 공통 스킬(grain-gradient-card)로 등록했다. HTML 발행 문서의 표지나 섹션 히어로, 기능 소개 카드 월을 제작할 때 바로 재사용할 수 있다.
- 정사각형이나 지정한 비율의 카드에 모서리 반경 20픽셀을 적용하고, 안쪽에는 두께 0.5픽셀의 반투명 테두리를 표시한다. 중앙 표제의 글자 크기는 카드 폭의 8%(8cqi)로 설정하되 최소 14픽셀을 유지한다.
- 바탕에는 위쪽이 밝고 차가우며 아래쪽이 어둡고 따뜻한 수직 그라디언트를 적용한다. 색상환에서 서로 이웃한 색만 사용해 방사형 색덩어리 4개를 만들고 모서리와 외곽에 배치한다.
- 오프스크린 Canvas로 128×128 크기의 회색조 노이즈 타일을 만든다. 타일의 표시 크기는 기기 픽셀비에 반비례하는 값(128 / dpr)으로 설정해 반복 배치하고, 혼합 모드는 오버레이, 불투명도는 0.40으로 설정한다.
- 두께 0.8픽셀, 불투명도 0.30 안팎의 흰색 SVG 세선 문양을 포갤 때는 방사형 마스크로 중앙 반경 18%를 비워 글자 가독성을 확보한다.
- 20~28초 주기로 천천히 유영하는 색덩어리와 호버 패럴랙스는 강성 70, 감쇠비 0.86의 스프링으로 묶고, 모션 축소 설정이 감지되면 모든 움직임을 그 자리에서 멈춘다.
@ianneo_ai의 X 포스트와 원본 레퍼런스 이미지를 분석해 원본 설계를 재구현했다.
다른 코딩 에이전트에게 그대로 복사해 건넬 수 있도록 지시 프롬프트와 핵심 구현 스크립트 세 편을 한자리에 모았다. 4종 프롬프트와 핵심 코드
결과물의 분위기만 적어 코딩 에이전트에게 이 카드 월을 맡기면 의도와 다른 화면이 나오기 일쑤다. 제약을 따로 걸지 않으면 CSS 반복 그라디언트로 가짜 노이즈를 흉내 내거나, 논리 픽셀 단위로 타일을 키워 입자를 뭉개고, 보색을 한데 섞어 중간색을 탁하게 흐려 놓는다. 아래 지시 프롬프트와 핵심 스크립트를 함께 건네면 첫 시도부터 똑같은 물리 픽셀 질감과 스프링 반응이 재현된다.
에이전트가 자주 빠지는 네 가지 함정
- 가짜 CSS 그라디언트 노이즈 금지: CSS 반복 방사형 그라디언트나 SVG 필터(feTurbulence)만 쓰면 고해상도 화면에서 브라우저가 픽셀 사이를 보간해 입자가 뿌옇게 뭉개진다. 오프스크린 Canvas에 128×128 무작위 회색조 픽셀을 찍은 뒤, 기기 픽셀비(devicePixelRatio)로 128을 나눈 크기로 타일을 깔라고 지정해야 한다.
- 보색 직접 혼합 금지: 파랑과 주황처럼 색상환 반대편의 두 색이 한 카드 안에서 바로 만나면 경계 구간이 회갈색으로 탁해진다. 상단과 하단 바탕은 물론 네 개의 색덩어리까지 색상환에서 이웃한 색으로만 묶어 색상 코드를 넘긴다.
- 세선 문양의 중앙 글자 침범 방지: 흰색 세선 문양이 카드 전면을 똑같은 진하기로 덮으면 중앙의 흰색 표제와 엉켜 글자가 묻힌다. 방사형 마스크(mask-image)로 중심 반경 18%를 비우고 52% 지점까지 서서히 드러나게 만든다.
- 고정 시간 전환 대신 스프링 적분 사용: 포인터 이동에 고정 시간 전환(transition)을 걸면 이동 도중에 커서가 방향을 틀 때 움직임이 뚝 끊긴다. 강성 70, 감쇠비 0.86의 2차 스프링 식을 매 프레임 적분해 속도가 매끄럽게 이어지게 한다.
외부 이미지나 외부 라이브러리 없이 순수 HTML, CSS, 오프스크린 Canvas, 인라인 SVG만으로 그레인 그라디언트 카드 월을 구현해 주세요.
1. 배치와 카드 골격
- 그리드 구성은 데스크톱에서 [3열 2행, 총 6장]으로 배치하고 모바일에서는 1열로 배치합니다.
- 각 카드는 정사각형(aspect-ratio: 1 / 1)으로 만듭니다. 모서리 반경 20px(border-radius: 20px), overflow: hidden, isolation: isolate, container-type: inline-size를 적용합니다.
- 카드 안쪽 테두리는 ::after 의사 요소에 box-shadow: inset 0 0 0 0.5px rgb(0 0 0 / 15%)를 적용하고 다크 모드에서는 rgb(255 255 255 / 16%)로 전환합니다.
- 중앙 표제는 카드 정중앙에 두 줄로 배치합니다. 흰색(#FFFFFF), 글자 크기 max(14px, 8cqi), 굵기 400, 줄간격 1.16, 자간 -0.018em를 적용합니다.
- 카드별 표제 목록은 [01 Product Teardowns / 02 Indie Builders Weekly / 03 AI Workflow Handbook / 04 Design Notes / 05 Late Night Shipping / 06 Build in Public] 순서로 넣습니다.
2. 카드 내부 5겹 레이어 순서 (아래에서 위 순서)
- 1층(.card-base)에는 위쪽이 밝고 차가운 색이고 아래쪽이 어둡고 따뜻한 색인 수직 선형 그라디언트를 적용합니다.
- 2층(.card-blobs)에는 카드 크기의 78%인 원형 색덩어리 4개를 네 모서리 외곽(-18% inset)에 배치하고 filter: blur(26px) saturate(112%)를 적용합니다. 색상환 반대편의 보색을 섞지 말고 반드시 색상환에서 서로 이웃한 색만 조합합니다.
- 3층(.card-pattern)에는 viewBox="0 0 400 400" 인라인 SVG로 흰색(#FFFFFF), 두께 0.8px, 불투명도 0.32의 기하학 세선 문양을 그립니다. 문양은 물결 방사선, 동심 등고선, 수평 음파선, 도트 매트릭스, 양볼록 렌즈 격자입니다. 중앙 표제가 잘 읽히도록 radial-gradient 마스크로 중심 반경 18%를 투명하게 비운 뒤 52% 지점까지 서서히 드러나게 합니다.
- 4층(.card-glow)에는 포인터 좌표를 따라가는 반경 160px의 흰색 방사형 소프트 글로우를 두고 호버 시에만 서서히 밝아지게 합니다.
- 5층(.card-grain)에는 오프스크린 Canvas(128x128)에서 생성한 무작위 회색조(R=G=B=0..255, A=255) 노이즈 타일을 덮습니다. background-size는 (128 / window.devicePixelRatio)px로 계산합니다. 여기에 image-rendering: pixelated, mix-blend-mode: overlay, opacity: 0.40을 적용해 노이즈 입자 1개가 물리 픽셀 1개에 대응하도록 맞춥니다.
3. 모션과 스프링 호버 반응
- 평상시에는 4개의 색덩어리에 21초에서 28초 주기의 서로 다른 @keyframes 부유 애니메이션을 적용합니다.
- 포인터가 움직일 때는 고정 시간 CSS 전환을 쓰지 말고, 강성(stiffness) 70, 감쇠비(damping ratio) 0.86의 2차 스프링 미분방정식을 requestAnimationFrame으로 적분합니다. 커서가 움직이면 색덩어리는 커서 방향으로 최대 +28px, 세선 문양은 반대 방향으로 최대 -14px 이동하게 하고, 그레인 타일은 0px로 고정합니다.
- 운영체제의 모션 축소(@media (prefers-reduced-motion: reduce))가 켜진 환경에서는 부유 애니메이션과 스프링 이동을 모두 중단합니다.
function applyPhysicalGrain(root = document.documentElement) {
const size = 128;
const dpr = Math.max(1, window.devicePixelRatio || 1);
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const imgData = ctx.createImageData(size, size);
const data = imgData.data;
let seed = 0x6D2B79F5;
function nextRand() {
seed |= 0;
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
for (let i = 0; i < data.length; i += 4) {
const v = Math.floor(nextRand() * 256);
data[i] = v;
data[i + 1] = v;
data[i + 2] = v;
data[i + 3] = 255;
}
ctx.putImageData(imgData, 0, 0);
root.style.setProperty("--grain-url", `url("${canvas.toDataURL("image/png")}")`);
root.style.setProperty("--card-grain-tile-size", `${(size / dpr).toFixed(3)}px`);
}
.grain-card {
container-type: inline-size;
position: relative;
display: grid;
place-items: center;
aspect-ratio: 1 / 1;
border-radius: 20px;
overflow: hidden;
isolation: isolate;
}
.card-blobs {
position: absolute;
inset: -18%;
z-index: 1;
filter: blur(26px) saturate(112%);
transform: translate3d(var(--blob-x, 0px), var(--blob-y, 0px), 0px);
}
.card-pattern {
position: absolute;
inset: -6%;
z-index: 2;
opacity: 0.32;
transform: translate3d(var(--pat-x, 0px), var(--pat-y, 0px), 0px);
-webkit-mask-image: radial-gradient(
circle at 50% 50%,
transparent 0%,
transparent 18%,
rgb(0 0 0 / 36%) 35%,
#000 52%,
#000 100%
);
mask-image: radial-gradient(
circle at 50% 50%,
transparent 0%,
transparent 18%,
rgb(0 0 0 / 36%) 35%,
#000 52%,
#000 100%
);
}
.card-grain {
position: absolute;
inset: 0;
z-index: 3;
background-image: var(--grain-url);
background-repeat: repeat;
background-size: var(--card-grain-tile-size, 128px) var(--card-grain-tile-size, 128px);
image-rendering: pixelated;
mix-blend-mode: overlay;
opacity: 0.4;
pointer-events: none;
}
const stiffness = 70;
const damping = 2 * 0.86 * Math.sqrt(stiffness);
function attachCardSpring(card) {
const state = { x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, raf: null, last: 0 };
function step(now) {
const dt = Math.min((now - (state.last || now)) / 1000, 1 / 30);
state.last = now;
state.vx += (-stiffness * (state.x - state.tx) - damping * state.vx) * dt;
state.vy += (-stiffness * (state.y - state.ty) - damping * state.vy) * dt;
state.x += state.vx * dt;
state.y += state.vy * dt;
card.style.setProperty("--blob-x", `${(state.x * 28).toFixed(2)}px`);
card.style.setProperty("--blob-y", `${(state.y * 28).toFixed(2)}px`);
card.style.setProperty("--pat-x", `${(state.x * -14).toFixed(2)}px`);
card.style.setProperty("--pat-y", `${(state.y * -14).toFixed(2)}px`);
const dist = Math.hypot(state.x - state.tx, state.y - state.ty);
const speed = Math.hypot(state.vx, state.vy);
if (dist < 0.002 && speed < 0.01) {
state.x = state.tx;
state.y = state.ty;
state.raf = null;
return;
}
state.raf = requestAnimationFrame(step);
}
card.addEventListener("pointermove", event => {
const rect = card.getBoundingClientRect();
state.tx = ((event.clientX - rect.left) / rect.width - 0.5) * 2;
state.ty = ((event.clientY - rect.top) / rect.height - 0.5) * 2;
card.style.setProperty("--glow-x", `${(event.clientX - rect.left).toFixed(1)}px`);
card.style.setProperty("--glow-y", `${(event.clientY - rect.top).toFixed(1)}px`);
if (!state.raf) {
state.last = 0;
state.raf = requestAnimationFrame(step);
}
});
card.addEventListener("pointerleave", () => {
state.tx = 0;
state.ty = 0;
if (!state.raf) {
state.last = 0;
state.raf = requestAnimationFrame(step);
}
});
}