Skip to content

디자인 지침

ranui 컴포넌트로 만든 화면이 부품 더미가 아니라 하나의 시스템으로 읽히도록 지켜야 할 규칙입니다.

이 페이지가 다루는 것은 판단입니다. 어떤 토큰에 손을 뻗을지, 내보내기 전에 무엇을 확인할지. 토큰 목록 자체는 디자인 시스템이고, 실행 중에 바꾸고 덮어쓰는 일은 테마가 다룹니다. 이 규칙들을 기계가 강제하는 완전판은 저장소의 packages/ranui/docs/DESIGN.md에 있습니다.

이럴 때 씁니다. 페이지를 배치하거나 <r-*> 요소로 앱 수준의 컴포넌트를 만들면서 색, 간격, 글자 크기, 그림자, 동작 시간을 정해야 할 때. 짧은 답은 늘 같습니다. 역할을 고르고, 값은 토큰에 맡기세요.

원칙

  1. 개성보다 명료함. 주된 과제와 주된 동작은 다른 무엇을 따지기 전에 헷갈릴 여지가 없어야 합니다.
  2. 조합하고, 다시 만들지 마세요. div로 원시 요소를 짓기 전에 r-button, r-input, r-select, r-modal에 손을 뻗으세요. 그 컴포넌트들에는 여러분이 다시 유도해야 할 포커스·키보드·ARIA 동작이 이미 들어 있습니다.
  3. 날값 대신 토큰. 16진수 코드, 20px 간격, 손으로 고른 그림자는 테마를 따라오지 못할 결정입니다.
  4. 눈대중이 아니라 역할과 상태로 정하세요. "이 글자는 무엇인가"(제목 / 레이블 / 본문 / 버튼)에는 답이 있지만, "어느 크기가 좋아 보이나"에는 답이 없습니다.
  5. 닿을 수 있는 모든 상태를 설계하세요. 기본, 호버, 활성, 포커스, 비활성, 로딩, 비어 있음, 오류. 기본은 여덟 중 하나일 뿐입니다.
  6. 그려진 것을 확인하세요. 라이트_와_ 다크에서, 좁은 폭_과_ 넓은 폭에서, 마우스_와_ 손끝에서. 리뷰는 보이지 않는 그림자를 잡아내지 못합니다.

두 규칙이 서로 다른 쪽으로 당길 때의 우선순위는 사용자의 목적 → 검증된 근거 → 이 지침 → 이미 내보낸 패턴 → 일반적인 경험칙입니다.

색 고르기

색은 역할과 상태로 배정하는 것이지 눈으로 고르는 것이 아닙니다. 호버와 활성이 어떤 모습인지는 사다리가 이미 정해 두었습니다. 여러분의 몫은 역할에 이름을 붙이는 일입니다.

이 요소는…이것을 쓰세요
페이지나 면의 배경--ran-color-bg / -bg-subtle / -bg-elevated / -bg-muted
포인터 아래 / 눌리는 중--ran-color-bg-hover / -bg-active
글자--ran-color-text / -text-secondary / -text-disabled
테두리--ran-color-border / -hover / -active
이 화면이 존재하는 이유인 동작--ran-color-primary(그 위에 --ran-color-primary-text)
상태--ran-color-success / -warning / -danger
링크--ran-color-link

강조색은 저마다 뜻이 하나뿐입니다. primary는 흑백입니다(라이트에서는 흰 바탕에 검정, 다크에서는 검은 바탕에 흰색). 그러니 거기에 파랑을 쓰지 마세요. 파랑은 링크와 포커스 링의 것입니다. 초록은 성공, 호박색은 경고, 빨강은 위험입니다. 강조하려고 빨강을 써 버리면, 나중에 위험을 나타낼 방법이 남지 않습니다.

조용히 망가지는 것을 막는 세 가지 규칙:

  • 테마를 따라야 할 값에 16진수나 rgb()를 그대로 박아 넣지 마세요.
  • 대체값은 테마에 따라 바뀌는 토큰을 가리켜야 합니다. var(--ran-color-text, var(--ran-gray-1000))이지, var(--ran-color-text, #171717)이 아닙니다. 라이트에서만 맞는 리터럴은 다크에서 사라집니다.
  • 대체값은 존재하는 토큰을 가리켜야 합니다. 선언되지 않은 속성에 건 var()는 아무것으로도 풀리지 않고, 선언 전체가 버려지며, 요소는 물려받은 값을 그대로 지닙니다. 그리고 그것은 대개 거의 맞아 보입니다. (--ran-color-error는 없습니다. --ran-color-danger입니다.)

간격과 리듬

모든 간격은 아홉 단계 스케일에서 가져오고, 거리 자체가 뜻을 지니게 하세요.

  • 한 묶음 안의 요소 사이는 8px.
  • 묶음과 묶음 사이는 16px.
  • 구역과 구역 사이는 32–40px.

20px이나 28px을 지어내지 마세요. 페이지의 리듬을 만드는 것은 제한된 선택지이고, 그것을 깨뜨리는 것은 스케일을 벗어난 간격 하나입니다. 영역을 가로지르는 공통의 등뼈(맞춰진 가장자리, 기준선, 열)를 지키고, 정렬은 눈이 아니라 그려진 픽셀로 확인하세요.

글자 고르기

그 글자가 어떤 역할을 하는지(제목, 레이블, 본문, 버튼, 고정폭) 물으면 글꼴·크기·굵기·행간이 모두 타입 스케일에서 따라 나옵니다. 인스턴스마다 날 px을 고르지 마세요.

역할은 도구이지 법이 아닙니다. 정말로 한 번뿐인 장식 글자(제스처가 번쩍이는 오버레이, 활성 링크의 굵기 조정)라면 가장 가까운 역할에 억지로 밀어 넣기보다 자기만의 컴포넌트 토큰을 갖는 편이 낫습니다.

깊이: 그림자와 쌓임

그림자 단계는 그 요소가 무엇인지로 고르고(흐름 속의 면, 떠 있는 오버레이, 앞을 막는 대화 상자), 그것이 실제로 보이는지 확인하세요. 보이지 않는 그림자는 깊이의 단서를 주지 못하고, 카드 단계로 내려앉은 오버레이는 페이지에 붙박여 보입니다.

여러분의 틀 안에 ranui 오버레이를 품을 때. z-index 사다리가 1000에서 시작하는 것은 바로 평범한 페이지 틀을 넘어서기 위해서입니다. 그러니 포털된 오버레이에는 여러분의 도움이 필요 없습니다. 다만 자기 섀도 DOM 안에 남아 있는 position: fixed 오버레이(r-modal의 대화 상자)는 가장 가까운 조상의 쌓임 맥락까지만 빠져나갑니다. 그러므로 품은 내용을 쌓임 맥락을 만드는 것(isolation, opacity < 1, transform, filter, will-change)으로 감쌌다면, 그 껍데기의 쌓임 단계를 올려 대화 상자가 다시 그 위에 오게 해야 합니다. 올리는 것은 오버레이가 실제로 열려 있는 동안으로 한정하세요.

css
.embed {
  isolation: isolate; /* 값싼 선택: 자기 z-index가 없으므로 아무것도 승격되지 않습니다 */
}
/* 진짜 오버레이가 열려 있을 때만 올립니다 — 결코 "혹시 몰라서"가 아니라 */
.embed:has(r-modal[open]),
.embed:has(r-modal[closing]) {
  position: relative;
  z-index: 100;
}

껍데기에 무조건 z-index를 주면 그 안의 모든 것(전혀 움직이지 않는 내용까지)이 스크롤하는 내내 고정 헤더 위로 들립니다. 이 버그는 다름 아닌 이 사이트에서 한 번 나갔습니다. open뿐 아니라 closing에도 맞추세요. 마스크는 open이 사라진 뒤에도 전환 시간만큼 계속 그려집니다.

모션

변화가 클수록 시간을 더 주고, 그 문턱 아래라면 아예 애니메이션하지 마세요. 호버와 활성 반응은 약 150ms, 메뉴는 약 200ms, 대화 상자는 약 300ms, 이미 뚜렷한 변화에는 0ms입니다. prefers-reduced-motion을 존중하세요.

팔레트 속성은 절대 트랜지션하지 마세요. CSS는 색이 바뀌었는지 알지 못합니다. 그래서 background-color, color, border-color, box-shadow, fill, stroke에 건 transition테마가 바뀔 때도 발동하고, 페이지의 나머지가 이미 넘어간 뒤에도 요소마다 제 속도로 서서히 물듭니다. 대신 움직임 속성(transform, opacity, 형상)을 애니메이션하세요. transition: alltransition: 0.2s 같은 맨 축약형은 팔레트 속성까지 포함해 _전부_를 뜻합니다. 둘 다 ranui 자체 스타일에서는 금지되어 있고, 여러분의 스타일에서도 나쁜 생각입니다.

상태와 문구

닿을 수 있는 상태는 모두 디자인의 일부입니다. 호버, 활성, 포커스, 비활성, 로딩, 비어 있음, 오류. 이것들을 사다리에 대응시키세요. 호버 → bg-hover / border-hover, 활성 → bg-active, 비활성 → text-disabled와 낮춘 불투명도, 포커스 → 포커스 링.

조작할 수 없는 것이 조작할 수 있어 보이면 안 됩니다. r-cardhoverable 어트리뷰트가 있을 때만 호버에 반응합니다. 누르지 않는 카드에는 붙이지 마세요.

문구도 시스템의 일부입니다.

  • 버튼은 동작 대상을 함께 씁니다. ✅ "멤버 삭제" ❌ "삭제", "확인".
  • 오류는 무슨 일이 있었는지 말하고, 이어서 어떻게 고치는지 말합니다. ✅ "빌드 실패: 번들이 크기 한도를 넘었습니다. 줄이거나 한도를 올리세요." ❌ "작업에 실패했습니다. 다시 시도해 주세요."
  • 확인과 알림은 성공이 아니라 변화를 말합니다. ✅ "프로젝트를 삭제했습니다" ❌ "삭제에 성공했습니다"(알림이 떴다는 사실이 이미 성공을 말합니다).
  • 맥락이 군더더기를 덜어 내게 하세요. "프로젝트 삭제"라는 제목의 대화 상자에 "프로젝트를 영구히, 완전히 삭제"라는 버튼은 필요 없습니다.

접근성

  • 글자와 배경의 대비는 WCAG AA를 만족시키세요.
  • 상태를 색만으로 알리지 마세요. 아이콘이나 레이블, 글과 짝지으세요.
  • 조작할 수 있는 모든 요소는 보이는 포커스 링을 지킵니다(--ran-focus-ring, 또는 outline: 2px solid var(--ran-color-primary); outline-offset: 2px). 깔끔해 보이려고 지우지 마세요.
  • 모든 것은 키보드로 닿을 수 있어야 합니다. 마우스 전용은 없습니다.
  • prefers-reduced-motionprefers-color-scheme을 존중하세요.

마우스와 손끝, 좁은 화면과 넓은 화면

입력 방식도 뷰포트도, 어느 한쪽이 뒷전인 대상은 아닙니다.

  • 끌기, 슬라이더, 제스처는 Pointer Events(pointerdown / pointermove / pointerup / pointercancel)를 쓰고, mouse*만으로 끝내지 마세요. 끄는 면 바로 그 자리에 touch-action: none을 함께 얹습니다. 포인터 핸들러 없이 touch-action: none만 선언한 CSS는 무해한 빈 줄이 아니라 망가진 컨트롤입니다.
  • 호버로만 드러나는 단서에는 탭으로 쓸 대안이 필요합니다. r-selectr-popovertrigger="hover"는 터치 기기에서 클릭으로 내려앉습니다. 여러분이 만드는 것도 그래야 합니다.
  • 중단점을 지어내기보다 뷰포트 기준 크기(%, min(), max(), clamp(), vw/vh, 예를 들어 min(560px, calc(100vw - 32px)))를 고르세요. ranui에는 공유하는 중단점 토큰이 없으므로, 못 박은 중단점은 모두 누군가가 관리해야 할 일회성 숫자가 됩니다.
  • 모바일에서 어떤 일을 할 수 있는 유일한 길을 숨기지 마세요. display: none 대신 흐름을 다시 잡으세요.
  • 잰 위치가 옳은 것은 다음 리플로까지입니다. getBoundingClientRect()에서 끌어낸 값은 크기 변경에서, 컨테이너 리플로에서, 그리고 (포털된 패널이라면) 스크롤에서 낡습니다. 처음 측정을 부른 상호작용에서만이 아니라 그 이벤트들에서도 다시 재세요. 좁은 폭으로 페이지를 여는 것은 초기 배치를 시험하는 일이지, 그 폭으로 크기를 바꾸는 일을 시험하지는 않습니다. 이 부류의 버그가 실제로 드러나는 곳은 후자입니다.

라이브러리가 기계적으로 강제하는 것

이 규칙 가운데 아홉 가지는 pnpm -F ranui verify:design이 확인하며, CI가 ranui 자체 소스에 대해 돌립니다. 다크에서 안전하지 않은 색 대체값, 날 색 리터럴, 간격 스케일, 크기 스케일, 마우스 전용 끌기 루프, hidden을 깨뜨리는 :host의 display 규칙, 선언되지 않은 토큰을 가리키는 대체값, 자기 섀도 트리를 query하는 컴포넌트, 생성자 밖에서 지어진 섀도 트리가 그것입니다. 알려진 위반은 기준선 파일에 고정되어 있어, 새 위반을 더할 수도 없고 고친 것이 조용히 되돌아갈 수도 없습니다.

이 관문이 지키는 것은 라이브러리이지 여러분의 애플리케이션이 아닙니다. 다만 그것이 잡아내는 실패 유형(없는 토큰을 가리키는 대체값, 라이트에서만 통하는 색)은 바로 리뷰에서 멀쩡해 보이는 것들입니다. 그러니 같은 규칙을 여러분의 CSS에도 적용할 값어치가 있습니다.

UI를 내보내기 전 점검 목록

  • [ ] 주된 과제와 주된 동작이 헷갈릴 여지가 없다.
  • [ ] 라이트와 다크에서, 좁은 폭과 넓은 폭에서 동작한다.
  • [ ] 마우스와 손끝으로 동작한다. 호버로 여는 것에는 탭 대안이 있다.
  • [ ] 모든 상태를 시험했다: 호버, 활성, 포커스, 비활성, 로딩, 비어 있음, 오류.
  • [ ] 키보드와 포커스를 확인했다. 포커스가 어디서나 보인다.
  • [ ] 가장자리 사례: 긴 글, 큰 숫자, 두 언어 모두.
  • [ ] 간격은 스케일에서, 글자는 역할로, 색은 시맨틱 토큰에서.
  • [ ] transition에 팔레트 속성이 없다. transition: all도 없다.
  • [ ] 문구가 대상을 이름으로 부른다. 색만으로 상태를 알리는 것이 없다.

MIT 라이선스로 배포됩니다.