
그동안 Android 개발이 BUILD에 가까웠다면 Web Full-stack은 CREATE의 영역이 훨씬 큰 것 같다. 그래서인지 재밌는 원리가 많아서 하루종일 웹 엔진과의 사투를 벌이고 있었다.
그러다가 한 때 활동했던 UMC에서 부탁이 들어왔다.
데모데이 랜딩페이지를 만들어달라는 총괄의 부탁이 있어서 첫 사이드프로젝트로 딱 좋겠다라고 생각했다.

화려한 이펙트를 넣고 그동안 네이티브 개발에서 상상만 하고 쉽게 하지 못했던 CREATE의 영역을 마음껏 휘젓고 다닐 수 있어서 디자인, 개발하느라 새벽까지 시간 가는 줄 모르고 놀았다.
최근 컨퍼런스 연사 자료도 만들면서 INTERACTIVE한 컴포넌트들을 재활용하는 경우가 생겼기에 이럴거면 Framer, Webflow, Lovable 같은 플랫폼을 만들어서 사내에서도 개발을 다른 사람한테 위임시키면 내가 개발이 아닌 사업의 영역에 가까운 일을 할 수 있겠다라는 생각 또한 들었다.
그렇게 시작한 게 glintkit이다. shadcn/ui가 기본적인 UI 프리미티브(Button, Dialog, Card)를 copy-paste 방식으로 제공하듯, glintkit은 3D 효과, Glassmorphism, WebGL 셰이더, 모션 애니메이션이라는 프리미엄 비주얼 영역을 같은 방식으로 제공한다.
npx glintkit add prismatic-burst
이 한 줄이면 프래그먼트 셰이더 기반의 프리즘 광선 효과가 내 프로젝트에 소스 코드 그대로 복사된다. npm 의존성도 아니고, 버전 락인도 없다. 코드를 소유하고, 자유롭게 수정할 수 있다.
glintkit의 핵심 설계 원칙은 세 가지다.

일반적인 CLI 도구는 node_modules 내부의 파일을 읽어서 복사한다. 하지만 이 접근은 문제가 있다. npx로 실행할 때 글로벌 캐시 경로가 OS마다 다르고, 권한 문제도 발생할 수 있다.
glintkit은 다른 방식을 선택했다. scripts/bundle-templates.ts가 빌드 시점에 /templates 디렉토리의 모든 파일을 순회하며, 각 파일의 내용을 JavaScript 문자열로 이스케이프해서 하나의 TypeScript 파일에 담는다.
// scripts/bundle-templates.ts (핵심 로직)
for (const filePath of files) {
const key = relative.replace(ext, "").replace(/\\/g, "/");
const content = fs.readFileSync(filePath, "utf-8");
const escaped = content
.replace(/\\/g, "\\\\")
.replace(/`/g, "\\`")
.replace(/\$/g, "\\$");
entries.push(` "${key}": \`${escaped}\``);
}
생성되는 결과물:
// src/registry/__generated__/templates.ts (자동 생성)
export const TEMPLATES: Record<string, string> = {
"components/3d/3d-card": `"use client";\n\nimport React, { ...`,
"components/3d/prismatic-burst": `"use client";\n\nimport { Renderer ...`,
"hooks/use-countdown": `import { useState, useEffect ...`,
// ... 18개 컴포넌트 전부
};
이 방식의 장점:
dist/index.js 하나로 완결node_modules 내 파일 탐색이 없으므로 경로 조작 공격 표면 감소템플릿에는 프로젝트 구조에 종속적인 import 경로를 placeholder로 남겨둔다:
import { cn } from "__UTILS_ALIAS__/cn";
import { useScrollAnimation } from "__HOOKS_ALIAS__/use-scroll-animation";
glintkit add 실행 시, file-writer.ts가 사용자의 glintkit.json 설정에 따라 치환한다:
// 사용자 프로젝트에 복사된 결과물
import { cn } from "@/lib/cn";
import { useScrollAnimation } from "@/hooks/use-scroll-animation";
이 메커니즘 덕분에 @/, ~/, 상대 경로 등 어떤 프로젝트 구조에서든 동작한다. glintkit init 시 tsconfig.json의 paths 설정을 자동 감지해서 별칭 프리픽스를 결정한다.
컴포넌트 간에는 의존 관계가 존재한다:

add.ts의 resolveAllDependencies 함수는 BFS로 전이적 의존성을 모두 해결한다:
function resolveAllDependencies(names: string[]): RegistryComponent[] {
const resolved = new Map<string, RegistryComponent>();
const queue = [...names];
while (queue.length > 0) {
const name = queue.shift()!;
if (resolved.has(name)) continue;
const comp = getComponent(name);
if (!comp) continue;
resolved.set(name, comp);
for (const dep of comp.registryDependencies) {
if (!resolved.has(dep)) queue.push(dep);
}
}
return Array.from(resolved.values());
}
npx glintkit add flip-card을 실행하면:
flip-card의 registryDependencies에서 ["cn", "3d-card"]를 발견3d-card의 registryDependencies에서 ["cn"]을 발견flip-card, cn, 3d-card 세 파일을 모두 복사일부 컴포넌트는 Tailwind만으로 표현하기 어려운 CSS가 필요하다 (예: Holo Card의 443줄짜리 홀로그래픽 CSS). 이 CSS는 사용자의 글로벌 CSS 파일에 마커 기반으로 주입된다:
.holo-card { ... }
.holo-card__shine { ... }
마커가 이미 있으면 내용만 교체하고, 없으면 파일 끝에 추가한다. 이 멱등성 덕분에 add를 여러 번 실행해도 CSS가 중복되지 않는다.
이 프로젝트에서 가장 재미있는 컴포넌트다. Three.js 대신 OGL (40KB 미만의 경량 WebGL 라이브러리)을 사용해, 커스텀 GLSL 프래그먼트 셰이더로 볼류메트릭 프리즘 광선을 실시간 렌더링한다.

Three.js는 번들 크기만 600KB+다. glintkit의 WebGL 컴포넌트는 장면 그래프, 조명 시스템, 지오메트리 로더가 필요 없다. 필요한 건 전체 화면 삼각형 하나와 프래그먼트 셰이더뿐이다.
import { Renderer, Program, Mesh, Triangle, Texture } from "ogl";
const renderer = new Renderer({ dpr: 1, alpha: false, antialias: false });
const triangle = new Triangle(gl);
const mesh = new Mesh(gl, { geometry: triangle, program });
OGL은 이 요구사항에 정확히 맞는다 — 극소 번들(40KB 미만), 직접적인 WebGL 제어, 커스텀 셰이더 퍼스트 설계.
정점 셰이더는 단순하다 — 전체 화면을 덮는 삼각형 하나를 그리고, UV 좌표를 프래그먼트 셰이더로 전달한다. 모든 비주얼은 프래그먼트 셰이더에서 만들어진다.
레이마칭 루프 (44 스텝):
for (int i = 0; i < 44; ++i) {
vec3 P = marchT * dir;
P.z -= 2.0;
float rad = length(P);
vec3 Pl = P * (10.0 / max(rad, 1e-6));
// 애니메이션 타입에 따라 3D 회전 적용
if(uAnimType == 0) Pl.xz *= M2;
else if(uAnimType == 1) Pl = rot3dMat * Pl;
else Pl = hoverMat * Pl;
// 레이 벤딩 — 광선이 곡선으로 휘어지는 효과
float a1 = amp * grow * bendAngle(Pl * 0.6, t);
float a2 = 0.5 * amp * grow * bendAngle(Pl.zyx * 0.5 + 3.1, t * 0.9);
Pb.xz = rot2(Pb.xz, a1);
Pb.xy = rot2(Pb.xy, a2);
float rayPattern = smoothstep(0.5, 0.7,
sin(Pb.x + cos(Pb.y) * cos(Pb.z)) *
sin(Pb.z + sin(Pb.y) * cos(Pb.x + t))
);
// 스펙트럴 그라디언트 — 레이 깊이에 따라 무지개색 변화
float saw = fract(marchT * 0.25);
float tRay = saw * saw * (3.0 - 2.0 * saw); // Hermite 보간
vec3 spectral = 2.0 * sampleGradient(tRay);
col += base * rayPattern;
marchT += stepLen;
}
핵심은 bendAngle 함수다. 세 개의 사인파를 중첩해서 광선이 프리즘을 통과할 때처럼 불규칙하게 휘어지는 효과를 만든다:
float bendAngle(vec3 q, float t) {
return 0.8 * sin(q.x * 0.55 + t * 0.6)
+ 0.7 * sin(q.y * 0.50 - t * 0.5)
+ 0.6 * sin(q.z * 0.60 + t * 0.7);
}
레이 카운트를 지정하면 cosine comb filter로 방사형 빔을 생성한다:
if (uRayCount > 0) {
float ang = atan(Pb.y, Pb.x);
float comb = 0.5 + 0.5 * cos(float(uRayCount) * ang);
comb = pow(comb, 3.0);
rayPattern *= smoothstep(0.15, 0.95, comb);
}
사용자가 colors prop으로 전달한 HEX 색상 배열은 1D 텍스처로 변환되어 GPU에 업로드된다:
const data = new Uint8Array(count * 4);
for (let i = 0; i < count; i++) {
const [r, g, b] = hexToRgb01(capped[i]);
data[i * 4 + 0] = Math.round(r * 255);
data[i * 4 + 1] = Math.round(g * 255);
data[i * 4 + 2] = Math.round(b * 255);
data[i * 4 + 3] = 255;
}
셰이더에서는 이 텍스처를 sampleGradient(t)로 샘플링해서 레이 깊이에 따른 색상을 결정한다. LINEAR 필터를 사용하므로 색상 간 자연스러운 보간이 이루어진다.
WebGL 없이도 렌즈 굴절 효과를 구현할 수 있다. Glass Surface는 SVG <feDisplacementMap> 을 활용한 색수차(Chromatic Aberration) 기법을 사용한다.

동작 원리:
1단계 — 변위 맵 생성: 런타임에 SVG를 동적으로 생성하고 data:image/svg+xml URI로 변환한다. 이 SVG에는 빨간색-파란색 그라디언트가 들어있어 각 축의 변위량을 제어한다.
2단계 — RGB 채널 분리: 같은 소스 이미지를 세 번 변위시키되, 각각 다른 scale 값을 적용한다:
[
{ ref: redChannelRef, offset: redOffset },
{ ref: greenChannelRef, offset: greenOffset },
{ ref: blueChannelRef, offset: blueOffset },
].forEach(({ ref, offset }) => {
ref.current.setAttribute("scale", (distortionScale + offset).toString());
});
distortionScale이 -180이고 각 채널 오프셋이 0, 10, 20이면, Red는 -180, Green은 -170, Blue는 -160으로 변위된다. 이 미세한 차이가 유리를 통해 보는 것 같은 색수차를 만든다.
3단계 — 채널 격리: feColorMatrix로 각 변위 결과에서 해당 색상 채널만 추출한다:
<!-- Red 채널만 추출 -->
<feColorMatrix type="matrix"
values="1 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 1 0" result="red"/>
4단계 — Screen 블렌딩: 세 채널을 screen 모드로 합성하면 원래 이미지에 색수차가 적용된 결과물이 완성된다.
핵심적인 판단이 있다 — backdrop-filter: url(#svg-filter-id)는 Chrome/Edge에서만 동작한다.
배포 후에 모바일로 확인해보니 Safari와 Firefox는 이 구문을 무시한다. 그래서 플랫폼에 따라 다른 UI를 표시해줘야하는 전략이 필요하다.
const supportsSVGFilters = () => {
const isWebkit =
/Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
const isFirefox = /Firefox/.test(navigator.userAgent);
if (isWebkit || isFirefox) return false;
const div = document.createElement("div");
div.style.backdropFilter = `url(#${filterId})`;
return div.style.backdropFilter !== "";
};
960줄짜리 이 컴포넌트는 CSS transform-style: preserve-3d만으로 3D 구체를 구현한다. WebGL이 아니다.
처음에는 파노라마처럼 흘러가는 것을 상상했으나 절대다수에게 공개되는만큼 사진 속에 대중들이 들어가있으면 프라이버시 이슈가 있을 것 같았다. 그래서 조직의 거대한 여정을 비추면서도 프라이버시를 지킬 방법이 없을까 고민하면서 설계하다가 흘러가는 지구본으로 타협을 봤다.

구체를 segments 수(기본 35)로 나누고, 각 타일의 위치를 구면 좌표로 결정한다:
.sphere-item {
transform:
rotateY(calc(var(--rot-y) * var(--offset-x) + var(--rot-y-delta)))
rotateX(calc(var(--rot-x) * var(--offset-y) + var(--rot-x-delta)))
translateZ(var(--radius));
}
rotateY → rotateX → translateZ(radius) 순서가 핵심이다. 먼저 적도를 따라 수평 회전하고, 위도 방향으로 수직 회전한 뒤, 반지름만큼 앞으로 밀어낸다. 결과적으로 각 타일이 구체 표면 위에 정확히 위치한다.
@use-gesture/react에서 제공하는 velocity 벡터를 받아 지수 감쇠(exponential decay)를 적용한다:
// 마찰 계수
const friction = 0.94 + 0.055 * dampening;
const animate = () => {
vX *= friction;
vY *= friction;
// 정지 임계값
if (Math.abs(vX) < 0.015 && Math.abs(vY) < 0.015) return;
const nextX = clamp(rotation.x - vY / 200, -maxDeg, maxDeg);
const nextY = wrapAngleSigned(rotation.y + vX / 200);
applyTransform(nextX, nextY);
requestAnimationFrame(animate);
};
수직 회전은 clamp으로 제한해 구체가 뒤집히는 것을 방지하고, 수평 회전은 wrapAngleSigned으로 -180~180도 범위를 순환시킨다.
CSS 3D로 구체를 만들면 175개 타일(35 segments × 5 rows)이 동시에 존재한다. WebGL과 달리 각 타일이 독립적인 DOM 요소이기 때문에, 최적화 없이는 모바일에서 프레임 드랍이 불가피하다.
1. 뷰포트 밖에서 애니메이션 중지
IntersectionObserver로 컴포넌트가 화면에 보이는지 추적한다. 보이지 않으면 auto-rotate의 requestAnimationFrame 루프가 transform 연산을 건너뛴다.
const observer = new IntersectionObserver(
(entries) => { isVisibleRef.current = entries[0].isIntersecting; },
{ threshold: 0.1 }
);
2. 스크롤 중 애니메이션 일시정지
스크롤 이벤트가 발생하면 isScrollingRef를 활성화하고, 스크롤이 멈춘 후 150ms 뒤에 해제한다. auto-rotate 루프에서 이 플래그를 체크하여 스크롤과 3D transform이 동시에 돌아가는 상황을 방지한다.
const handleScroll = () => {
isScrollingRef.current = true;
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => { isScrollingRef.current = false; }, 150);
};
window.addEventListener('scroll', handleScroll, { passive: true });
auto-rotate 루프에서 이 조건들을 모두 체크한다:
if (draggingRef.current || focusedElRef.current || inertiaRAF.current
|| !isVisibleRef.current || isScrollingRef.current) {
autoRotateRAF.current = requestAnimationFrame(animate);
return; // transform 연산 스킵
}
3. Ref 기반 상태 관리로 리렌더 제거
rotationRef, draggingRef, isVisibleRef 등 모든 실시간 상태를 useState 대신 useRef로 관리한다. 드래그/관성 애니메이션은 초당 60번 값이 바뀌는데, 매번 React 리렌더를 트리거하면 175개 타일이 전부 재평가된다. ref를 쓰면 DOM만 직접 업데이트하고 React 렌더 사이클은 건너뛸 수 있다.
// useState였다면 매 프레임 리렌더 발생
const rotationRef = useRef({ x: 0, y: 0 });
const applyTransform = (xDeg: number, yDeg: number) => {
// React를 거치지 않고 DOM 직접 조작
sphereRef.current.style.transform =
`translateZ(calc(var(--radius) * -1)) rotateX(${xDeg}deg) rotateY(${yDeg}deg)`;
};
4. backface-visibility: hidden으로 뒷면 타일 제거
3D 구체는 항상 절반이 뒷면을 향한다. backface-visibility: hidden을 적용하면 브라우저가 뒷면 타일의 렌더링을 완전히 건너뛴다. 175개 중 ~87개의 페인트 비용이 사라진다.
.sphere-item { backface-visibility: hidden; }
.item__image {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
드래그 중 이전 프레임과 회전값이 동일하면 DOM 조작 자체를 건너뛴다:
const cur = rotationRef.current;
if (cur.x !== nextX || cur.y !== nextY) {
rotationRef.current = { x: nextX, y: nextY };
applyTransform(nextX, nextY);
}
will-change: transform과 translateZ(0)으로 타일을 자체 컴포지팅 레이어로 승격시킨다. 개별 타일이 변경될 때 전체가 아닌 해당 레이어만 리페인트된다.
.sphere { will-change: transform; }
.item__image {
-webkit-transform: translateZ(0);
transform: translateZ(0);
}
WebGL 없이 CSS만으로 포켓몬 카드 스타일의 홀로그래픽 효과를 구현한다.
Simon Goellner의 그 유명한 pokemon-cards-css 레포지토리

핵심은 마우스 좌표를 CSS Custom Properties에 매핑하는 것이다:
const mx = ((e.clientX - left) / width) * 100;
const my = ((e.clientY - top) / height) * 100;
const hyp = Math.sqrt(px * px + py * py) / 50;
card.style.setProperty("--mx", `${mx}%`);
card.style.setProperty("--my", `${my}%`);
card.style.setProperty("--hyp", hyp.toString());
CSS에서는 이 변수를 기반으로 여러 그라디언트 레이어를 조합한다:
--mx와 --my를 따라 이동하는 무지개 그라디언트--hyp)에 비례하는 스페큘러 하이라이트rotateX/rotateY에 +-15도 범위로 적용requestAnimationFrame 배칭으로 레이아웃 스래싱을 방지하고, will-change: transform으로 GPU 합성을 힌트한다.
카타카나 문자가 무작위로 섞이다가 하나씩 잠기면서 최종 숫자를 드러내는 매트릭스 스타일 효과다.

메인 컨셉 컬러가 초록 시안색이고 컴퓨터와 관계가 있다면 자연스럽게 영화 매트릭스가 떠오를 수밖에 없고 challenger라는 동아리의 페르소나와 AI 시스템에 대항하는 네오의 페르소나와 겹쳐서 적용했다.

const CYBER_CHARS =
"ア イ ウ エ オ カ キ ク ケ コ 0 1 2 3 4 5 6 7 8 9 A B C D E F # $ % & @";
알고리즘:
duration / (자릿수 + 1) 간격으로 왼쪽부터 하나씩 잠근다
IntersectionObserver 기반의 useScrollAnimation 훅으로 뷰포트에 들어올 때 한 번만 실행된다. tabular-nums CSS 속성으로 숫자 폭이 일정하게 유지된다.
WebGL 컴포넌트는 무분별하게 사용하면 프레임 드랍의 원인이 된다. glintkit은 세 가지 레벨의 자동 최적화를 적용한다.
// IntersectionObserver로 화면 밖이면 렌더링 중지
const io = new IntersectionObserver(
(entries) => {
isVisibleRef.current = entries[0].isIntersecting;
checkVisibility();
},
{ threshold: 0.01 }
);
// document.hidden 상태도 감지
document.addEventListener("visibilitychange", () => checkVisibility());
스크롤 중에는 60fps WebGL 렌더링이 불필요하다:
const onScroll = () => {
isScrollingRef.current = true;
checkVisibility(); // 렌더링 중지
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
isScrollingRef.current = false;
checkVisibility(); // 렌더링 재개
}, 150);
};
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReducedMotion) return; // WebGL 렌더링 전체 스킵
배경 효과는 Retina 해상도가 필요 없다:
const dpr = 1; // 항상 1x로 렌더링 — 4배 적은 픽셀 처리
Storybook에서 컴포넌트를 미리보기 하려면 __COMPONENTS_ALIAS__ 같은 플레이스홀더를 실제 경로로 해석해야 한다. 이를 위해 커스텀 Vite 플러그인을 작성했다:
// .storybook/main.ts 내부
const glintAliasPlugin = {
name: "glintkit-alias",
resolveId(source: string) {
if (source.startsWith("__COMPONENTS_ALIAS__/")) {
const name = source.replace("__COMPONENTS_ALIAS__/", "");
return path.resolve(
templatesDir,
`components/${componentMap[name] || name}.tsx`
);
}
// __HOOKS_ALIAS__, __UTILS_ALIAS__도 동일하게 처리
},
};
이 플러그인 덕분에 Storybook은 templates/ 디렉토리의 원본 소스를 직접 임포트해서 라이브 프리뷰를 제공한다. 빌드된 CLI와 동일한 컴포넌트를 별도 빌드 없이 개발 환경에서 확인할 수 있다.
glintkit/
├── src/
│ ├── cli/
│ │ ├── index.ts # Commander.js 진입점
│ │ └── commands/
│ │ ├── init.ts # 프로젝트 초기화
│ │ ├── add.ts # 컴포넌트 추가 (핵심)
│ │ └── list.ts # 컴포넌트 목록
│ ├── registry/
│ │ ├── components.ts # 18개 컴포넌트 레지스트리
│ │ ├── css-presets.ts # CSS 프리셋 정의
│ │ └── __generated__/
│ │ └── templates.ts # 자동 생성된 템플릿 번들
│ └── utils/
│ ├── config.ts # 설정 파일 관리
│ ├── file-writer.ts # 별칭 치환 + 파일 쓰기
│ ├── css-injector.ts # 멱등 CSS 주입
│ ├── detect-pm.ts # 패키지 매니저 감지
│ └── installer.ts # 의존성 설치
├── templates/ # 컴포넌트 소스 원본
│ ├── components/
│ │ ├── 3d/ # 3D Card, Holo Card, Prismatic Burst, ...
│ │ ├── glass/ # Button, Card, Modal, Music Player
│ │ └── motion/ # Counter, Countdown, Shiny Text, Light Rays
│ ├── hooks/ # useCountdown, useScrollAnimation, useMediaQuery
│ └── utils/ # cn (clsx + tailwind-merge)
├── scripts/
│ └── bundle-templates.ts # 빌드 시 템플릿 번들링
└── .storybook/ # Storybook 설정 + 커스텀 Vite 플러그인
glintkit은 "3D, Glassmorphism, WebGL 컴포넌트를 shadcn/ui처럼 배포할 수 있을까?"라는 질문에서 시작했다. 그 과정에서 몇 가지 흥미로운 엔지니어링 문제를 풀어야 했다.
이 컴포넌트들은 다른 프로젝트를 하거나 비슷한 디자인 컨셉을 적용할 때 쉽게 재활용해서 써야겠다.
그런데 벌써부터 디자인에 권태가 와서 또 갈아엎을 것 같다.
npx glintkit init && npx glintkit add --all
UMC 9기로 활동중인 사람입니다!
웹개발자를 희망하는 사람으로서 데모데이 랜딩페이지를 보며 너무 멋있고 배워가고 싶다고 생각했었는데, 이렇게 글로 남겨주셔서 많은 도움이 되었습니다. 좋은 글 감사합니다!