본문으로 건너뛰기

디자인 언어

Neba의 표면은 성형된 플라스틱 키가 아니라 잘라낸 아크릴 판입니다. 이 한 문장이 아래 모든 규칙의 근거입니다. 새 컴포넌트를 만들 때 판단이 서지 않으면 이 문장으로 돌아오세요.

이 문장이 무엇을 취하고 무엇을 버리는지는 다음과 같습니다.

  • 취한 것: 표면 위쪽 가장자리에 걸리는 빛, 반투명과 배경 흐림, 단계가 분명한 elevation 척도.
  • 버린 것: 아래쪽 어두운 베벨, 광택과 굴절, 큼지막한 크기, 항상 켜져 있는 그림자.

1. 표면

모든 채워진 표면은 네 겹으로 이루어집니다. 순서와 강도가 정해져 있습니다.

토큰역할
채움--neba-{color}-fill색. --neba-fill-alpha(88%)만큼 불투명
배경 흐림--neba-blurblur(9px) saturate(1.5)
그레인--neba-grainoverlay로 합성되는 노이즈 타일
가장자리--neba-plate-solid위쪽 밝은 선 + 전체 1px 흰 hairline

반투명은 흐림과 함께 조정합니다

alpha를 낮추는 것만으로는 유리가 되지 않습니다. 뒷배경이 얼마나 읽히는지는 흐림 반경이 결정합니다. 16px에서는 뒤에 있던 격자선이 하나의 균일한 색으로 번져 버려서, alpha를 아무리 내려도 표면이 다시 불투명하게 읽힙니다. 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의 표면은 색칠되는 대상 자체이기 때문입니다.

세 단계가 명도가 아니라 불투명도로 올라가는 것도 같은 이유입니다. 상태가 올라갈수록 시트가 빛을 더 머금을 뿐, 회색으로 가지 않습니다.

부작용 하나. 테두리가 없는 solid Box/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)만 씁니다.

tick에는 plate를 넣지 않습니다

plate는 1px짜리 선이므로, 그것이 표면에서 차지하는 비중은 표면의 크기에 달려 있습니다. 32px 버튼에서는 잘린 모서리에 걸린 빛이지만, 18px 체크박스나 18px 라디오, 20px 스위치 트랙에서는 물체의 15분의 1 두께로 그린 베벨이 됩니다. 그렇게 작은 것에 그만한 베벨이 붙으면 2008년의 툴바 아이콘처럼 보입니다.

그래서 Checkbox, Radio, Switch는 아크릴만 유지하고 --neba-plate-glass--neba-plate-solid는 쓰지 않습니다. 상자를 판으로 보이게 하는 것은 grain과 sheen, backdrop blur입니다. 라이브러리에서 plate를 두르지 않는 컨트롤은 이 셋뿐입니다.

sheen은 비대칭입니다

linear-gradient(148deg, …). 위에서 아래로 곧게 떨어지는 대칭 그라디언트는 광원이 어디에 있는지 말해 주지 못하고, 표면을 아크릴이 아니라 그저 칠해진 면으로 보이게 만듭니다. 각도를 살짝 기울이면 빛이 한쪽에서 들어온 것으로 읽힙니다.


2. 색

이 절은 색을 그렇게 정한 이유를 다룹니다. 토큰의 실제 값과 덮어쓰는 방법은 에 있습니다.

기준색은 #4072cd이고, 나머지는 그 색의 배색표에서 뽑았습니다.

역할유래
primary기준색
secondary기준색의 색조를 남긴 슬레이트
success트라이아딕 녹색을 톤다운
warning보색 앰버
dangersplit-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을 지킬 수 있는 채움 명도는 50% 안팎의 좁은 구간으로 묶입니다. 채움을 더 밝히고 싶다면 글자를 어둡게 바꾸는 수밖에 없습니다. warning이 실제로 그렇게 하는 유일한 계열입니다. hover와 active 단계까지 모두 같은 기준으로 검증합니다.

색조를 몇 도 옮기는 것도 선택지입니다. success는 152 → 148, info는 218 → 223으로 옮겼습니다. 둘 다 중간 명도에서 sRGB가 특히 좁아지는 구간이라, 원래 색조에서는 어떻게 조정해도 나오지 않는 채도가 몇 도 옆에서 나옵니다.

파생 블록은 테마 루트마다 반복됩니다. 커스텀 프로퍼티는 선언된 요소에서 var()를 해석합니다. 파생 토큰을 :root에만 두면 .dark 하위 트리에서도 라이트 테마 값으로 고정됩니다. 그래서 파생 블록의 선택자는 :root, .dark, .light, [data-theme='dark'], [data-theme='light']입니다.

다크 모드에서 채움을 밝히지 마세요

다크 테마에서 파스텔 채움에 어두운 글자를 얹는 방식은 흔하지만, 어두운 화면에서는 채움 자체가 광원이 되어 눈이 아픕니다. Neba는 다크에서도 채움을 중간 명도로 유지하고 글자는 흰색으로 둡니다. 밝아지는 것은 표면 위에서 읽혀야 하는 accent뿐입니다.

다크 모드의 글자색은 띄운 면 위에서 잽니다

panel 사다리는 불투명도로 만듭니다. --neba-panel과 그 위 두 단계는 뒤에 무엇이 있든 그 위에 흰색을 얹습니다. 흰 페이지에서는 이게 아무것도 바꾸지 않아서, --neba-surface 위에서 잰 라이트 테마 값이 Card 안에서도 popup 안에서도 테이블 헤더 아래에서도 똑같이 읽힙니다. 검정에 가까운 페이지에서는 단계마다 바닥의 휘도가 곱해지고, 그게 겹칩니다. Card 하나가 페이지의 2.1배, 그 Card 안의 헤더 띠가 4.9배입니다. 맨 면에 맞춰 고른 글자색은 띄운 면에 올라가는 순간 대비를 대부분 잃습니다.

그래서 --neba-muted-fg--neba-disabled-fg, --neba-border는 Card 안의 테이블 헤더에 맞춰 고릅니다. 라이브러리가 스스로 쌓는 가장 깊은 바닥이고, 거기서 사다리를 되짚어 내려오며 확인합니다. 그 바닥에서 muted 글자색은 5.05:1, 바깥에 페이지 셸까지 두르면 4.34:1입니다. 라이트 테마가 어디서나 읽히는 값이 4.33~4.64:1입니다.

다크의 사다리는 5/7/9%이고, 예전에는 7/10/13%이었습니다. 그 값들은 sheet가 페이지 위에서 어떻게 읽히는지를 보고 골랐고, 확인한 바닥도 페이지뿐이었습니다. 겹치면 테이블 헤더가 맨 면의 7.5배가 되면서 글자색 위계 전체를 위쪽 끝으로 밀어 넣었습니다. 낮추는 비용은 생각보다 작습니다. 라이트의 사다리는 sheet의 명도를 아예 움직이지 않고, 거기서 Card의 시작을 말해 주는 것은 하이라인과 plate edge입니다.

경계선은 약해지는 정도가 아니라 뒤집힙니다. 라이트에서 border는 자기가 그려지는 모든 바닥보다 조금 어둡습니다. 맨 면에 맞춰 고른 다크 값은 띄운 면보다 어두워지므로, 빛을 받아야 할 모서리가 검은 흠집처럼 읽힙니다. Card 위의 TreeView 안내선과 차트 grid가 그랬습니다. 그래서 다크의 경계선은 사다리 전체보다 위에 두었고, 민무늬 페이지에서는 라이트보다 또렷합니다. 움직이는 사다리 위에서 한 값이 모든 단계에서 속삭일 수는 없습니다.

물든 바닥 위의 글자는 accent가 아니라 on-tint입니다

panelsoft는 둘 다 계열 자신의 accent를 옅게 푼 워시입니다. 그 위에 accent로 글자를 쓰면 자기 자신의 옅은 사본 위에 자기 색을 얹는 셈입니다. 글자와 바닥이 함께 움직이니 어느 한쪽만 바꿔서는 간격이 벌어지지 않습니다. 워시가 9%를 넘어가는 순간 그 쌍은 4.5:1에 닿을 수 없고, soft-press는 25%입니다. 켜진 Toggle, text Chip, 하이라이트된 menu row, 포인터가 올라간 outline Button이 전부 3.4:1에서 4.5:1 사이였습니다. 두 테마 모두 그랬습니다.

--neba-{color}-on-tint가 그 바닥들의 글자색입니다. 채움의 글자색이 on-solid인 것과 같은 자리입니다. accent--neba-fg 쪽으로 28% 당긴 값이라 테마에 따라 방향이 알아서 뒤집힙니다. 흰 페이지에서는 검정 쪽으로, 어두운 페이지에서는 흰색 쪽으로 갑니다. 계열의 색조는 남고, 두 사다리의 모든 단계에서 여섯 계열이 전부 4.5:1을 넘깁니다. accent 자체는 그대로라 맨 면 위의 TextLink와 Statistic의 증감, Alert 제목은 예전 그대로입니다.

warning은 글자가 어둡습니다

앰버 위에 흰 글자는 어떤 명도에서도 4.5:1을 넘지 못합니다. --neba-warning-on-solid만 어두운 갈색입니다. 대비를 지키려고 계열 색을 왜곡하는 대신 글자색을 바꾸는 쪽이 맞습니다.


3. 크기와 밀도

size: 높이와 타입 스케일

xssmmdlgxl
높이22px26px32px40px48px
글자11px12px13px15px17px
반경10px12px14px18px22px

간격이 균등하지 않은 것은 의도입니다. md는 데스크톱 주력, xs/sm은 툴바와 테이블 행, lg/xl은 화면의 주인공 액션입니다. 4px씩 균등하게 올라가면 다섯 단계가 사실상 두 단계처럼 보입니다.

xl의 48px는 모바일 터치 타겟(44px 이상)을 만족합니다.

그린 것과 누르는 것은 다른 상자입니다

tick, switch, Chip의 ×는 높이 사다리 위에 있지 않습니다. 옆에 있는 글자에 맞춰 크기가 정해지는데, 글자는 손가락보다 작습니다. md의 tick은 18px이고 WCAG 2.5.8이 요구하는 값은 24px입니다.

그래서 이 컨트롤들은 .neba-hit을 답니다. 아무것도 그리지 않는 ::before가 눌리는 상자만 키우고, 모자란 축만 키웁니다. switch는 어느 단계에서든 이미 24px보다 넓으므로 위아래로만 자랍니다. 그린 것은 1px도 바뀌지 않습니다.

높이 사다리 위의 컨트롤에는 붙이지 않습니다. xs Button도 22px로 모자라지만, Button은 다른 Button 옆에 놓이고 라벨 자체가 타겟입니다. 가장자리 너머로 2px을 넓히면 옆 버튼을 누를 자리를 침범하게 됩니다.

반경은 높이의 45%

50%면 알약이 됩니다. 45%에서 멈추면 위아래에 직선 구간이 남고, 그것이 "모서리를 깎아낸 판"으로 읽히게 합니다.

density: 여백만 바꿉니다

default   10 / 12 / 16 / 20 / 24px
compact    6 /  8 / 10 / 12 / 16px

density는 높이도 타입 스케일도 건드리지 않습니다. 같은 size의 컨트롤은 밀도가 달라도 높이가 같고, 그래서 밀도가 섞인 행에서도 기준선이 유지됩니다. 두 트랙의 비가 대략 2:1이라 한눈에 구분됩니다.


4. Elevation

ts
type NebaElevation = 0 | 1 | 2 | 3;

기본값은 0이고, 0은 그림자가 전혀 없다는 뜻입니다. 표면을 배경에서 분리하는 것은 아크릴 가장자리이지 그림자가 아닙니다. 진짜로 콘텐츠 위에 떠 있는 표면에만 올리세요.

호버하면 한 단계 오르고, 누르면 한 단계 내려갑니다. 그래서 elevation 0인 컨트롤도 누르면 반응하되 그림자가 생기지는 않습니다. 레벨 4는 레벨 3을 호버했을 때만 도달합니다.

그림자에 컨트롤 자신의 색을 섞지 마세요. 컬러 글로우는 작은 컨트롤에서 가장 과한 표현이 됩니다. --neba-shadow-*는 전부 중립색입니다.

portal로 띄우는 표면은 z-index 하나를 공유합니다

Menu, Dialog, Drawer, Tooltip, Toast는 작성된 자리가 아니라 문서 끝에 그려지고, 전부 --neba-z-portal을 읽습니다. 기본값은 50입니다.

이 숫자는 라이브러리의 추측일 뿐이고, 실제로 결정하는 쪽은 사이트입니다. 고정 헤더가 1200에 있는 사이트라면 한 줄로 올립니다.

css
:root {
  --neba-z-portal: 1400;
}

라이브러리는 popup끼리 층을 나누지 않으므로 값 하나면 전부 덮습니다. 표면 안쪽에서 쓰는 작은 z-index(고정 테이블 헤더, 차트 tooltip)는 그 컴포넌트 안에서만 의미가 있고 밖으로 나가지 않습니다.


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 엘리먼트도 타이머도 없습니다.

css
.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;
}

컨트롤 안에서 움직여도 되는 것은 indicator뿐입니다

위 규칙은 라벨이 얹힌 상자, 즉 컨트롤에 적용됩니다. 그 안의 표식은 경우가 다릅니다. 글자를 담지 않아 리샘플링될 것이 없고, 표식 자체가 상태를 나타냅니다. 그래서 움직여도 되며, transform이 아니라 실제 속성 위에서 움직입니다.

움직이는 표식은 넷뿐입니다. Switch의 thumb은 left로 이동합니다. Checkbox의 체크 표시는 pathLength="1"로 정규화한 path 위에서 stroke-dashoffset을 따라 스스로 그려집니다. Radio의 점은 ring 중앙에서 width/height로 자랍니다. Rating의 채움은 포인터가 있는 별까지 width로 쓸려갑니다.

넷 중 어느 것도 scale하지 않습니다. 1.4배에서 시작해 제자리로 줄어드는 표식은 오는 동안 두 번 리샘플링되는데, 그것이 바로 컨트롤에 transform을 쓰지 않는 이유입니다.

움직이지 않는 액자 안의 사진은 크기를 바꿔도 됩니다

Galleryhover="zoom"이 그 예외이며, 기본값이 아니라 직접 켜야 동작합니다. 움직이는 것은 타일 안의 사진이고 타일 자신의 가장자리는 제자리에 있습니다. 액자는 overflow: hidden이며 크기가 바뀌지 않으므로 페이지의 다른 요소가 밀리지 않습니다. 사진에는 라벨이 없어 리샘플링될 글자도, 커서 아래에서 흔들릴 글자도 없습니다.

기본값은 깊이를 바꾸는 lift와 색을 바꾸는 dim입니다. 라이브러리의 나머지가 pointer에 반응하는 방식과 같으며, zoom을 지정하지 않은 갤러리는 확대하지 않습니다.

떠 있는 표면은 opacity로만 등장하고 사라집니다

라이브러리의 모든 popup, panel, sheet, backdrop, toast는 fade합니다. 슬라이드도, 스케일도, 와이프도 없습니다. popup은 대부분 글자이기 때문입니다. 포인터가 향하고 있던 메뉴 행, 읽기 시작한 dialog, 손가락 아래의 달력 칸이 모두 글자이고, 표면이 이동하면 그 글자가 전부 함께 이동합니다.

선언 하나와 상태 클래스 둘이며, internal/styles.ts에 한 번 쓰여 모든 popup이 그것을 읽습니다. 컴포넌트마다 따로 정의하면 popup 하나가 나머지와 다르게 동작할 여지가 생깁니다.

opacity 외에 바뀌어도 되는 것은 표면 자신의 크기뿐이며, 그것도 읽는 사람이 조작했을 때만입니다. 높이가 다른 두 메뉴 사이에서 NavigationMenu의 panel이 크기를 바꾸는 것은 panel이 제자리에 있으면서 내용만 바뀌는 경우입니다.

Drawer만 예외입니다. 다른 floating surface는 머무를 자리에 그대로 나타나므로, 움직이면 읽는 사람이 보고 있던 글자까지 함께 끌려갑니다. Drawer는 다릅니다. side가 prop이고, panel은 그 가장자리에 고정되며, 열리기 전에는 화면 밖에 있습니다. 그래서 밀어 넣어도 읽히고 있던 것은 하나도 움직이지 않고, 반대로 fade로 띄우면 Dialog와 구별되는 유일한 성질을 잃습니다. panel은 자기 가장자리에서 들어오고 뒤의 scrim만 fade합니다. 모든 플랫폼이 쓰는 조합이며, 이 라이브러리에서 움직임이 장식이 아니라 정보를 전달하는 유일한 자리입니다.

포인터 스포트라이트

.neba-glow::before가 커서를 따라다니는 옅은 블룸입니다. 컴포넌트는 pointermove에서 --n-mx/--n-my 두 값을 요소의 인라인 스타일에 직접 씁니다.

React state를 쓰지 마세요. 이 이벤트는 포인터 레이트로 발생하므로 setState는 마우스가 움직일 때마다 트리를 리렌더합니다. 좌표는 getBoundingClientRect() 대신 offsetX/offsetY로 읽어 강제 리플로우를 피합니다. 아이콘에는 pointer-events: none이 걸려 있어 오프셋이 항상 컨트롤 기준입니다.

두 레이어 모두 @media (hover: hover)prefers-reduced-motion을 존중합니다.

포커스 링은 나타나지 않고 도착합니다. 필드에서는 가장자리에 붙습니다

링은 아예 선언하지 않는 대신 평소에 두께 0으로 선언해 둡니다. 포커스가 바꾸는 것은 그 두께입니다. outline-style은 중간값이 없는 속성이라 포커스 상태에만 존재하는 링은 출발할 자리가 없지만, 두께에는 있습니다. 링은 바로 아래 하이라인과 같은 duration으로 움직입니다. 둘은 같은 가장자리이고, 한쪽만 즉시 바뀌면 두 가지 일이 일어나는 것처럼 보입니다.

필드의 셸에서는 링이 그 가장자리에 붙습니다. 필드의 하이라인은 포커스가 닿는 순간 링과 같은 색으로 바뀌므로, 2px 띄운 링은 그 사이에 페이지 색 띠를 낀 두 번째 선이 됩니다. 가장자리가 두꺼워진 것이 아니라 컨트롤이 후광을 두른 것처럼 보이는 모양입니다.

그 밖에서는 링이 계속 띄워져 있고, 이유는 취향이 아니라 대비입니다. 칠해진 컨트롤에 링을 붙이면 같은 계열의 fill 위에 링이 얹히는데, --n-fill 위의 --n-ring은 믿고 쓸 수 있는 대비가 아닙니다. 필드가 예외인 것은 그 시트가 염료 없는 panel이기 때문입니다.

화려함의 상한

눌린 느낌을 만드는 것은 채움 색이 어두워지는 변화이고, 빛 효과는 그 위에 얹히는 하이라이트일 뿐입니다. --neba-flash-on-fill이 스포트라이트보다 한 단계만 밝은 것도 그래서입니다.


6. 상태

세 가지 상태는 각자 다른 축으로 말해야 합니다. 기본 상태와의 차이가 무엇인지가 한눈에 구분되어야 합니다.

상태표현이유
disabled색 계열을 완전히 버리고 중립 회색색을 흐리게만 하면 "여전히 주요 액션인데 흐릿함"으로 읽힙니다
loading겉모습 그대로, 스피너가 startIcon 자리를 차지진행 중일 뿐 사용 불가가 아닙니다
readOnly색은 유지, 평평하게 + saturate(0.55)버튼 모양을 한 라벨

disabled만 네이티브 disabled 속성을 씁니다. loadingreadOnlyaria-disabled로 표시하고 포커스는 유지한 채 핸들러에서 활성화를 막습니다.

투명도로 상태를 표현하지 마세요. opacity: 0.5는 어떤 상태든 "흐릿함"으로만 읽힙니다. 상태마다 다른 축(채도, 색 계열, 평탄도)을 쓰세요.


7. 구현 규칙

상태 분기는 CSS가 아니라 JS에서

같은 명시도를 가진 Tailwind variant 둘은 생성된 스타일시트에서의 순서로 승부가 갈립니다. 컴포넌트가 의존해도 되는 성질이 아닙니다.

ts
// 이렇게
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-* 슬롯을 인라인 스타일로 생성합니다.

ts
'--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이 되어 포커스 링이 통째로 사라집니다. 단축 속성 하나로 쓰세요.

[outline:0_solid_var(--n-ring)] outline-offset-2 focus-visible:[outline:2px_solid_var(--n-ring)]

평소 상태의 선언은 포커스 상태가 출발하는 자리이고, 포커스 쪽은 두께만이 아니라 단축 속성 전체를 씁니다. normalize를 비롯한 여러 사이트 테마가 :focus-visible { outline: auto }를 싣고 있는데, 그쪽은 선택자 하나라서 class 하나로만 맞서면 생성 순서가 승부를 정해 버립니다.

그 옆에 [outline:none]을 덧붙이는 것도 같은 실수입니다. 특정도가 같은 outline 선언이 둘이면 어느 쪽이 이기는지는 의도가 아니라 Tailwind가 생성한 순서가 정합니다. 링은 이미 두께 0으로 선언되어 있고, 브라우저 기본 outline을 지우는 일도 그 선언이 합니다.

Released under the MIT License