디자인 언어
Neba의 표면은 성형된 플라스틱 키가 아니라 잘라낸 아크릴 판입니다. 이 한 문장이 아래 모든 규칙의 근거입니다. 새 컴포넌트를 만들 때 판단이 서지 않으면 이 문장으로 돌아오세요.
Fluent UI의 입체감, Liquid Glass의 우아함, Material의 정돈됨을 섞되, 각각의 과한 부분은 의도적으로 뺐습니다.
- Fluent에서 가져온 것: 표면 위쪽 가장자리에 걸리는 빛. 버린 것: 아래쪽 어두운 베벨.
- Liquid Glass에서 가져온 것: 반투명과 배경 흐림. 버린 것: 광택과 굴절.
- Material에서 가져온 것: 명확한 elevation 척도. 버린 것: 큼지막한 크기와 항상 떠 있는 그림자.
1. 표면
모든 채워진 표면은 네 겹으로 이루어집니다. 순서와 강도가 정해져 있습니다.
| 겹 | 토큰 | 역할 |
|---|---|---|
| 채움 | --neba-{color}-fill | 색. --neba-fill-alpha(88%)만큼 불투명 |
| 배경 흐림 | --neba-blur | blur(9px) saturate(1.5) |
| 그레인 | --neba-grain | overlay로 합성되는 노이즈 타일 |
| 가장자리 | --neba-plate-solid | 위쪽 밝은 선 + 전체 1px 흰 hairline |
반투명은 흐림과 함께 조정합니다
alpha를 낮추는 것만으로는 유리가 되지 않습니다. 흐림 반경이 뒷배경의 가독성을 결정합니다. 16px에서는 뒤에 있는 격자선이 균일한 색으로 뭉개져 결국 불투명해 보입니다. 9px로 잡아서 뒤에 무언가 있다는 것은 느껴지되 읽히지는 않는 지점에 두었습니다.
88%는 흰 페이지 위에서 흰 글자 대비 4.5:1을 지키는 하한선입니다. 채움 색의 명도는 불투명 기준이 아니라 이 88% 기준으로 골랐습니다. alpha를 내리려면 명도도 함께 내려야 합니다.
염료 없는 판(--neba-glass-bg)은 예외입니다
위 규칙은 염료가 들어간 채움에 대한 것입니다. outline·text 변형과 Card·Box·TextField가 기본으로 깔고 앉는 --neba-glass-bg는 색이 없는 판이라 alpha가 유일한 조절축입니다. 여기서 alpha는 "뒤가 읽히는가"가 아니라 **"이 판이 흰색으로 읽히는가"**를 결정합니다.
라이트 테마에서 42%는 자기 자신보다 뒤 페이지를 더 많이 통과시켰습니다. 순백이 아닌 배경 위에서는 그 배경의 회색이 그대로 올라와 판 전체가 칙칙해집니다. 66%로 올린 이유가 이것입니다 — 흐림은 9px 그대로라 뒤가 읽히지 않는 것도 그대로입니다. 다크 테마는 같은 이유로 5% → 7%.
컨테이너 표면은 염색하지 않습니다
Box·Card·TextField의 표면은 --neba-panel / -hover / -press, 즉 염료가 전혀 없는 흰 시트 세 단계입니다. 계열별 --neba-{color}-panel을 쓰지 않습니다.
컨테이너가 담는 것은 남의 콘텐츠이고, 그건 이미 자기 색을 가지고 옵니다 — 본문, 링크, 버튼, 필드. 그 아래 시트를 물들이면 그 전부가 자기가 선택되지 않은 배경 위에 놓입니다. 그래서 계열은 하이라인·포커스 링·캐럿에서 멈추고 시트는 흰색으로 남습니다.
컨트롤은 정반대라 계열별 --neba-{color}-panel을 그대로 씁니다. Button의 표면은 색칠되는 대상 자체이기 때문입니다.
사다리가 명도가 아니라 불투명도인 것도 같은 이유입니다. 상태가 올라갈수록 시트가 빛을 더 머금을 뿐, 회색으로 가지 않습니다.
부작용 하나. 테두리가 없는
solidBox/Card에서는color가 닿을 곳이 없어 보이는 변화가 없습니다. 컨테이너에서color는 사실상 가장자리 색을 고르는 prop입니다.
그레인이 아크릴을 만듭니다
반투명 + 흐림만으로는 매끄러운 유리가 됩니다. 거칠거칠한 아크릴로 만드는 것은 노이즈입니다. feTurbulence(fractalNoise, 3옥타브) 120px 타일 하나를 data URI로 넣고 background-blend-mode: overlay로 채움 위에 얹습니다. 타일 자체 alpha는 12%이며, 이미지는 한 번 디코드되어 페이지 전체가 재사용합니다.
가장자리에 어두운 베벨을 넣지 마세요
inset 0 -1px 0 black은 표면을 즉시 사출성형 플라스틱으로 만듭니다. 위쪽 밝은 선(--neba-plate-top)과 전체를 두르는 흰 hairline(--neba-plate-edge)만 씁니다.
sheen은 비대칭입니다
linear-gradient(148deg, …). 위에서 아래로 떨어지는 대칭 그라디언트는 모든 프레임워크가 쓰는 형태이고, Neba가 Bootstrap처럼 보이게 만드는 가장 큰 요인이었습니다.
2. 색
여기는 색을 그렇게 정한 이유를 적는 곳입니다. 토큰이 실제로 어떤 값인지, 어떻게 덮어쓰는지는 색에 있습니다.
기준색은 #4072cd이고, 나머지는 그 색의 배색표에서 뽑았습니다.
| 역할 | 유래 |
|---|---|
primary | 기준색 |
secondary | 기준색의 색조를 남긴 슬레이트 |
success | 트라이아딕 녹색을 톤다운 |
warning | 보색 앰버 |
danger | split-complementary 주홍 |
info | 유사색 시안 |
색은 전부 oklch()로 정의합니다. 명도 축이 지각과 일치해서, 여섯 계열의 밝기를 같은 숫자로 맞출 수 있기 때문입니다.
계열마다 손으로 정하는 값은 5개뿐
--neba-{color}-solid 채움 기준색
--neba-{color}-solid-hover -4.5 명도
--neba-{color}-solid-active -12 명도
--neba-{color}-on-solid 채움 위 글자색
--neba-{color}-accent 표면 위에서 읽히는 색 (text/outline 변형용)나머지(-fill, -panel, -soft, -line, -ring)는 파생 블록에서 color-mix()로 계산됩니다. 색 계열을 추가할 때 손댈 곳은 두 군데뿐입니다 — NebaColor 유니언과 styles.css의 5줄.
채도는 gamut 한계까지, 명도는 대비가 허락하는 만큼만
계열이 칙칙해 보일 때 원인은 대개 명도가 아니라 채도입니다. sRGB에서 oklch()의 chroma 상한은 색조와 명도마다 다르고, 그 상한을 한참 밑돌면 같은 밝기에서도 회색에 가깝게 읽힙니다. Neba의 chroma는 각 계열의 명도에서 gamut 상한의 90% 근처에 둡니다 — 선명하되 브라우저가 clip할 일은 없는 지점입니다.
명도는 그렇게 자유롭지 않습니다. on-solid가 흰 글자이고 채움이 88%면, 흰 페이지 위에서 4.5:1을 지킬 수 있는 채움 명도는 40대 후반 ~ 50대 초반에 묶입니다. 채움을 더 밝히고 싶으면 글자를 어둡게 바꾸는 수밖에 없습니다 — warning이 정확히 그렇게 하고 있는 유일한 계열입니다. hover와 active 단계까지 전부 이 기준으로 검증합니다.
색조를 몇 도 옮기는 것도 선택지입니다.
success는 152 → 148,info는 218 → 223으로 옮겼습니다. 둘 다 중간 명도에서 sRGB가 특히 좁아지는 구간이라, 원래 색조에서는 어떻게 조정해도 나오지 않는 채도가 몇 도 옆에서 나옵니다.
파생 블록은 테마 루트마다 반복됩니다. 커스텀 프로퍼티는 선언된 요소에서
var()를 해석합니다. 파생 토큰을:root에만 두면.dark하위 트리에서도 라이트 테마 값으로 고정됩니다. 그래서 파생 블록의 선택자는:root, .dark, .light, [data-theme='dark'], [data-theme='light']입니다.
다크 모드에서 채움을 밝히지 마세요
Material식으로 다크에서 파스텔 채움 + 어두운 글자를 쓰면 어두운 화면에서 눈이 아픕니다. Neba는 다크에서도 채움을 중간 명도로 유지하고 흰 글자를 씁니다. 밝아지는 것은 accent(표면 위 글자색)뿐입니다.
warning은 글자가 어둡습니다
앰버 위에 흰 글자는 어떤 명도에서도 4.5:1을 넘지 못합니다. --neba-warning-on-solid만 어두운 갈색입니다. 대비를 지키려고 계열 색을 왜곡하는 대신 글자색을 바꾸는 쪽이 맞습니다.
3. 크기와 밀도
size — 높이와 타입 스케일
| xs | sm | md | lg | xl | |
|---|---|---|---|---|---|
| 높이 | 22px | 26px | 32px | 40px | 48px |
| 글자 | 11px | 12px | 13px | 15px | 17px |
| 반경 | 10px | 12px | 14px | 18px | 22px |
간격이 균등하지 않은 것은 의도입니다. md는 데스크톱 주력, xs/sm은 툴바와 테이블 행, lg/xl은 화면의 주인공 액션입니다. 4px씩 균등하게 올라가면 다섯 단계가 사실상 두 단계처럼 보입니다.
xl의 48px는 모바일 터치 타겟(44px 이상)을 만족합니다.
반경은 높이의 45%
50%면 알약이 됩니다. 45%에서 멈추면 위아래에 직선 구간이 남고, 그것이 "모서리를 깎아낸 판"으로 읽히게 합니다.
density — 여백만 바꿉니다
default 10 / 12 / 16 / 20 / 24px
compact 6 / 8 / 10 / 12 / 16pxdensity는 높이도 타입 스케일도 건드리지 않습니다. 같은 size의 컨트롤은 밀도가 달라도 높이가 같고, 그래서 밀도가 섞인 행에서도 기준선이 유지됩니다. 두 트랙의 비가 대략 2:1이라 한눈에 구분됩니다.
4. Elevation
type NebaElevation = 0 | 1 | 2 | 3;기본값은 0이고, 0은 그림자가 전혀 없다는 뜻입니다. 표면을 배경에서 분리하는 것은 아크릴 가장자리이지 그림자가 아닙니다. 진짜로 콘텐츠 위에 떠 있는 표면에만 올리세요.
호버하면 한 단계 오르고, 누르면 한 단계 내려갑니다. 그래서 elevation 0인 컨트롤도 누르면 반응하되 그림자가 생기지는 않습니다. 레벨 4는 레벨 3을 호버했을 때만 도달합니다.
그림자에 컨트롤 자신의 색을 섞지 마세요. 컬러 글로우는 작은 컨트롤이 낼 수 있는 가장 시끄러운 소리입니다. --neba-shadow-*는 전부 중립색입니다.
5. 움직임
컨트롤은 움직이지 않습니다
transform을 쓰지 마세요. 컨트롤을 스케일하면 라벨까지 리샘플링되고, 커서 아래에서 글자가 떨리는 것은 다른 모든 절제를 무효로 만듭니다. 상태 변화는 색과 깊이로만 표현합니다.
누를 때는 즉시, 뗄 때는 천천히
이 비대칭이 Neba 인터랙션의 핵심 장치입니다. 같은 원리를 두 곳에서 씁니다.
채움 색 — 속성별 duration을 주고, :active에서 전부 0ms로 덮습니다.
transition-property: background-color, border-color, box-shadow, color;
transition-duration: var(--neba-duration-fill), var(--neba-duration), …; /* 340ms, 160ms… */&:active { transition-duration: 0ms }누르는 프레임에 색이 확 들어가고, 떼면 340ms에 걸쳐 빠져나갑니다.
잔광 레이어 — 같은 방식으로 opacity에 적용합니다. JavaScript도 ripple 엘리먼트도 타이머도 없습니다.
.neba-glow::after {
opacity: 0;
transition: opacity 900ms cubic-bezier(0.22, 1, 0.36, 1);
}
.neba-glow:active::after {
opacity: 1;
transition-duration: 0ms;
}포인터 스포트라이트
.neba-glow::before가 커서를 따라다니는 옅은 블룸입니다. 컴포넌트는 pointermove에서 --n-mx/--n-my 두 값을 요소의 인라인 스타일에 직접 씁니다.
React state를 쓰지 마세요. 이 이벤트는 포인터 레이트로 발생하므로 setState는 마우스가 움직일 때마다 트리를 리렌더합니다. 좌표는 getBoundingClientRect() 대신 offsetX/offsetY로 읽어 강제 리플로우를 피합니다. 아이콘에는 pointer-events: none이 걸려 있어 오프셋이 항상 컨트롤 기준입니다.
두 레이어 모두 @media (hover: hover)와 prefers-reduced-motion을 존중합니다.
화려함의 상한
빛 효과는 눌린 느낌을 만드는 주역이 아니라 그 위에 얹히는 하이라이트입니다. 주역은 채움 색이 어두워지는 것입니다. --neba-flash-on-fill이 스포트라이트보다 겨우 한 단계 밝은 이유입니다.
6. 상태
네 가지 상태는 서로 다른 것을 말해야 합니다.
| 상태 | 표현 | 이유 |
|---|---|---|
disabled | 색 계열을 완전히 버리고 중립 회색 | 색을 흐리게만 하면 "여전히 주요 액션인데 흐릿함"으로 읽힙니다 |
loading | 겉모습 그대로, 스피너가 startIcon 자리를 차지 | 진행 중일 뿐 사용 불가가 아닙니다 |
readOnly | 색은 유지, 평평하게 + saturate(0.55) | 버튼 모양을 한 라벨 |
| 기본 | — | — |
disabled만 네이티브 disabled 속성을 씁니다. loading과 readOnly는 aria-disabled로 표시하고 포커스는 유지한 채 핸들러에서 활성화를 막습니다.
투명도로 상태를 표현하지 마세요.
opacity: 0.5는 어떤 상태든 "흐릿함"으로만 읽힙니다. 상태마다 다른 축(채도, 색 계열, 평탄도)을 쓰세요.
7. 구현 규칙
상태 분기는 CSS가 아니라 JS에서
같은 명시도를 가진 Tailwind variant 둘은 생성된 스타일시트에서의 순서로 승부가 갈립니다. 컴포넌트가 의존해도 되는 성질이 아닙니다.
// 이렇게
disabled ? disabledClasses[variant] : readOnly ? readOnlyClasses[variant] : restClasses[variant];
// 이렇게 하지 말 것 — data-disabled: 와 data-readonly: 의 우선순위는 정의되지 않습니다
('data-disabled:bg-gray-200 data-readonly:bg-blue-500');색 슬롯은 인라인 스타일로
Tailwind는 소스에 문자 그대로 나타나는 클래스 이름만 봅니다. [--n-fill:var(--neba-primary-fill)]을 계열마다 하드코딩하면 색 하나 추가에 클래스 수십 개가 따라옵니다. 대신 --n-* 슬롯을 인라인 스타일로 생성합니다.
'--n-fill': `var(--neba-${color}-fill)`;이것이 Tailwind를 벗어나도 되는 유일한 종류의 이유입니다. Tailwind가 표현할 수 없을 때만 벗어나세요.
읽을 수 없어지면 CSS 파일로
.neba-glow가 Tailwind 유틸리티가 아니라 styles.css의 실제 클래스인 이유는, [&::before]:[background:radial-gradient(…)] 형태가 기술적으로는 가능하지만 유지보수가 불가능하기 때문입니다. 의사 요소에 그라디언트를 얹는 종류의 스타일은 CSS로 쓰세요.
outline-none을 쓰지 마세요
Tailwind v4의 outline-* 유틸리티는 스타일을 --tw-outline-style로 통과시킵니다. 요소에 outline-none이 있으면 그 변수가 none이 되어 포커스 링이 통째로 사라집니다. 단축 속성 하나로 쓰세요.
focus-visible:[outline:2px_solid_var(--n-ring)] focus-visible:outline-offset-2