디자인 언어
Plass의 표면은 맑은 시트 위에 놓인, 색이 들어간 유리 키입니다. 아래의 모든 규칙은 이 한 문장에서 나옵니다. 새 컴포넌트를 만들다 확신이 서지 않으면 이 문장으로, 그리고 이 문장이 모든 표면에 던지는 질문으로 돌아오세요.
이것은 눌리는 것인가, 무언가를 담는 것인가?
눌리는 것은 색이 들어간 유리입니다. 색 계열의 두 끝 사이를 훑는 그러데이션, 그 계열로 드리우는 그림자, 그리고 포인터를 따라 표면 위를 옮겨 다니는 빛. 무언가를 담는 것은 맑은 유리입니다. 반투명하고, 강하게 흐려져 있고, 흰 hairline을 두르고, 절대 물들지 않습니다. 세 번째 답은 없으며, 세 번째가 필요해 보이는 컴포넌트는 사실 두 개의 컴포넌트입니다.
이 문장이 가져온 것과 버린 것:
- 가져온 것: 빛의 방향, 컨트롤이 드리우는 그림자로 번지는 색, 그리고 포인터와 함께 도착하는 빛.
- 버린 것: 모든 종류의 요철(bevel, specular highlight, 키 아래의 어두운 선, 부피감) 그리고 컨트롤을 움직여 표현하는 모든 상태.
여기가 Plass가 플라스틱 라이브러리이기를 그만둔 지점입니다. 초안은 채워진 컨트롤을 성형된 키로 그렸습니다. 한쪽 모서리에서 밝아지고 반대쪽에서 어두워지는 세 지점 그러데이션에, 그 위 절반을 덮는 specular highlight를 얹어 마무리하는 식이었습니다. 하나하나는 앞뒤가 맞았지만 결과는 전부 래커칠처럼 읽혔습니다. 그 자리를 대신한 것이 아래이고, 결국 한 가지 생각입니다. 음영을 지는 그러데이션이 아니라, 방향을 트는 그러데이션.
1. 두 가지 재질
색이 들어간 유리
solid 표면은 세 겹이고, 세 겹뿐인 것이 의도입니다.
| 겹 | 토큰 | 하는 일 |
|---|---|---|
| 채움 | --plass-{color}-fill | 135°의 두 지점 그러데이션 |
| tint | --plass-{color}-tint | 컨트롤 자신의 색으로 드리우는 drop shadow |
| glow | .plass-glow | 포인터를 따라 움직이는 빛 |
채움은 그러데이션이고, 그 그러데이션은 음영을 지는 대신 방향을 틉니다. 두 지점은 색 계열의 두 끝이며 같은 밝기 입니다. primary는 인디고에서 애저로, danger는 버밀리언에서 로즈로, success는 초록에서 틸로, info는 파랑에서 시안으로 갑니다. 밝아지는 것도 어두워지는 것도 없습니다.
highlight 레이어가 없는 이유가 바로 이것입니다. 한쪽 모서리로 어두워지는 그러데이션은 램프를 받고 있는 성형된 물체이고, 그런 물체는 환영을 완성하기 위해 specular highlight를 필요로 합니다. 채워진 컨트롤이 래커칠처럼 읽히게 만든 것이 정확히 그것이었습니다. 방향을 트는 그러데이션은 색이 들어간 유리판이고, 유리판에는 더 필요한 것이 없습니다.
채워진 컨트롤에는 highlight도 bevel도 없습니다. 위쪽 가장자리의 흰 inset 선(inset 0 1px 0 white)은 색이 칠해진 표면을 성형품처럼 보이게 만드는 가장 빠른 방법이고, 그 아래의 inset 0 -1px 0 black이 두 번째입니다. hairline은 glass의 것입니다. 유리에는 빛이 걸릴 실제 절단면이 있기 때문입니다. solid 컨트롤에는 그림자 하나가 있고 가장자리는 없습니다.
어디서나 135°. 채움과 elevation이 모두 왼쪽 위에서 오는 빛을 전제합니다. 이 중 하나가 다른 곳을 가리키면 물체가 두 개로 읽힙니다.
유리
콘텐츠를 담는 모든 것은 세 단계의 시트 하나입니다.
--plass-glass rgb(255 255 255 / 0.62) 기본
--plass-glass-hover rgb(255 255 255 / 0.76)
--plass-glass-press rgb(255 255 255 / 0.88)사다리는 밝기가 아니라 불투명도입니다. 표면은 관여될수록 회색이 되는 대신 더 많은 빛을 머금습니다.
blur가 곧 재질입니다. blur(22px) saturate(160%). 의도적으로 후한 번짐입니다. Plass는 시트 뒤를 읽게 하려는 것이 아니라 시트를 두꺼워 보이게 하려는 것입니다. 14px 아래로 내려가면 유리는 유리이기를 그만두고 알파가 걸린 흰 상자가 됩니다.
유리는 물들지 않습니다. 시트는 다른 사람의 콘텐츠를 담고, 그 콘텐츠는 자기 색을 가지고 옵니다. 본문 글자, 링크, 버튼, 필드. 아래 시트를 물들이면 그 전부가 자신이 선택된 적 없는 배경 위에 놓입니다. 그래서 계열은 hairline과 focus ring, 캐럿에서 멈추고 유리는 맑게 남습니다.
컨트롤은 반대 경우여서 계열을 채움 자체에 들입니다. PlButton의 표면은 색이 칠해지는 대상 그 자체 이기 때문입니다.
한 가지 결과.
glassPlButton에서color는 라벨과 hairline입니다.glassPlTextField에서color는 hairline과 ring, 캐럿뿐입니다. invalid가 된 필드가 표면을 다시 칠하지 않고도 계열 전체를danger로 넘길 수 있는 이유가 이것입니다.
하나뿐인 inset 그림자
--plass-well은 이 짝을 증명하는 예외입니다. 라이브러리에서 유일하게 안쪽으로 향하는 그림자입니다. solid PlTextField가 그려지는 방식이며(가장 불투명한 유리에 빛이 안으로 떨어지는 형태) 캐럿과 텍스트 선택, placeholder 아래에 깔린 그러데이션은 읽히지 않기 때문입니다.
그래서 solid는 눌리는 것에서는 "색이 들어간 판", 입력하는 것에서는 "가장 깊은 맑은 유리"를 뜻합니다. 같은 단어이고, 그 아래 규칙은 하나입니다. solid는 그 variant가 제 일을 하면서 가질 수 있는 가장 무거운 것입니다.
2. 색
이 절은 왜 에 대한 것입니다. 토큰이 실제로 어떤 값이고 어떻게 덮어쓰는지는 색에 있습니다.
기준 색은 #3558ef이고 나머지는 그 팔레트에서 나옵니다.
| 역할 | 어디서 왔는지 |
|---|---|
primary | 기준 색 |
secondary | 기준 색의 hue를 유지한 slate |
success | 흰 글자를 얹을 만큼 어두운 초록 |
warning | 보색 앰버, 유일하게 어두운 잉크를 쓰는 계열 |
danger | 톤을 낮춘 버밀리언 |
info | 유사색 azure |
계열마다 손으로 고르는 값 셋
--plass-{color}-solid 훑기의 한쪽 끝이자 계열의 정체성
--plass-{color}-solid-to 반대쪽 끝
--plass-{color}-on-solid 두 지점 위의 잉크
--plass-{color}-accent 표면 위에서 읽히는 색 — 테마별나머지(-fill, -tint, -soft, -line, -ring)는 파생 블록에서 color-mix()로 계산됩니다. 색 계열 추가는 두 번의 편집입니다, PlassColor union에 항목 하나, styles.css에 세 줄, 그리고 각 테마의 accent.
테마가 바뀌어도 같은 키 색
파란 유리판은 어두운 방에서도 같은 파란 유리판입니다. 다크 모드에서 바뀌는 것은 그것이 놓인 바닥입니다. 맑은 유리는 흰빛을 잃고 스모크 판이 되고, 그림자는 검게 깊어지며, 그 그림자로 번지는 tint는 강해집니다. 거의 검은 페이지 위에서는 색이 물든 그림자가 앉을 자리가 거의 없기 때문입니다.
테마별로 바뀌는 유일한 계열 값은 accent입니다. 바라보는 색이 아니라 읽어야 하는 색이기 때문입니다.
두 끝은 대비 하한에 최대한 붙여 둡니다
모든 지점이 자기 on-solid에 대해 4.5:1을 넘기고, 그 전부가 정확히 4.5에서 0.15 이내에 있습니다. 반대 방향의 소심함이 아닙니다. 하한보다 필요 이상으로 높이 띄운 계열은 필요 이상으로 어두운 계열이고, 버튼이 전부 한 톤씩 깊게 깔린 팔레트야말로 팔레트가 조용히 어긋나는 가장 흔한 방식입니다.
그래서 제약은 양쪽입니다. 하한이 계열이 얼마나 밝을 수 있는지를 정하고, 그보다 어둡게 만드는 것은 무엇도 허용되지 않습니다.
warning은 어두운 글자를 씁니다
앰버라 부를 만한 어떤 밝기에서도 흰 글자는 4.5:1에 닿지 않습니다. --plass-warning-on-solid는 이 세트에서 유일한 짙은 갈색입니다. 잉크를 바꾸는 것이 옳은 답이고, 흰 라벨을 지키려고 계열을 일그러뜨리는 것은 아닙니다.
3. 크기와 density
size: 높이와 타입 스케일
| xs | sm | md | lg | xl | |
|---|---|---|---|---|---|
| 높이 | 24px | 32px | 40px | 48px | 56px |
| 글자 | 11px | 13px | 14px | 16px | 18px |
| radius | 8px | 10px | 12px | 14px | 16px |
사다리는 한 단계에 8px씩 일정하고, 조밀한 데스크톱 툴킷보다 높은 데서 시작합니다. 재질이 자리를 요구하기 때문입니다. 방향을 틀어야 하는 그러데이션과 hairline을 32px 안에 넣는 것은 열한 픽셀의 채움을 두고 두 효과가 싸우는 일입니다. xs는 테이블 행을 위해 존재하며, 훑는 거리가 짧아 두 끝이 거의 맞닿는 유일한 단계입니다.
lg 48px과 xl 56px은 모두 모바일 터치 타깃 44px을 넘깁니다.
radius는 fillet
높이보다 훨씬 느리게 자랍니다. xs에서 33%, md에서 29%, xl에서 29%. 거의 일정한 그 radius가 크기가 다른 두 컨트롤을 같은 틀에서 뽑은 두 조각으로 읽히게 합니다. 높이의 비율로 묶은 radius는 대신 작은 알약과 큰 사각형을 만들어 냅니다.
density: 여백, 오직 여백
default 10 / 12 / 16 / 24 / 28px
compact 6 / 8 / 10 / 14 / 16pxdensity는 높이도 타입 스케일도 건드리지 않습니다. 같은 size의 두 컨트롤은 density와 무관하게 높이가 같아서, density가 섞인 줄도 기준선을 유지합니다. 두 트랙은 대략 2:1이라 차이가 한눈에 읽힙니다.
4. Elevation
type PlassElevation = 0 | 1 | 2 | 3;PlButton의 기본값은 1, PlTextField는 0입니다. 키는 시트 위에 놓이고, 필드는 시트 안으로 파입니다. 호버는 한 단계를 올리고 누르면 한 단계를 내리므로, 기본 버튼은 유리에 딱 닿는 데까지 내려갔다가 원래 자리로 돌아옵니다.
사다리는 offset보다 blur로 훨씬 많이 올라갑니다. 들렸을 때 페이지에서 20px씩 내려가는 표면은 이미 시트를 떠난 것이고, 이 라이브러리의 모든 것은 여전히 시트 위에 앉아 있습니다.
그림자는 물들고, Plass가 절제와 갈라서는 지점이 여기입니다
--plass-{color}-tint는 컨트롤이 자기 색으로 드리우는 drop shadow이고, 이 디자인 언어에서 단연 가장 시끄러운 요소입니다. 파란 버튼과 파랑으로 만들어진 버튼의 차이가 이것입니다.
이것은 의도적으로 elevation 사다리의 일부가 아니며 함께 커지지 않습니다. elevation은 표면이 페이지에서 얼마나 떠 있는지를, tint는 표면이 무엇으로 만들어졌는지를 말합니다. 한 단계 높은 danger 버튼이 더 붉은 유리판은 아닙니다.
tint를 가지는 것은 solid뿐입니다. 맑은 유리 시트는 중립적인 그림자를 드리웁니다. 자기 색이 없기 때문입니다.
5. 모션
컨트롤은 움직이지 않습니다
컨트롤에 transform을 쓰지 마세요. 컨트롤을 확대하면 라벨이 리샘플링되고, 커서 아래에서 아른거리는 글자는 나머지 전부가 들이고 있는 절제를 무너뜨립니다. 상태 변화는 오직 빛과 깊이로 표현합니다.
눌리는 대신 콘텐츠를 담는 표면(Card나 행)은 떠올라도 되고, 떠올라야 합니다. 이 규칙은 손가락 아래에 있는 것에 대한 규칙입니다.
위치에 애니메이션을 주는 곳은 PlSlider의 thumb 하나뿐이고, 그것도 예외가 아닙니다. 제자리에서 밀려나는 것은 없고, 움직이는 것이 곧 값이기 때문입니다. 끌지 않은 이동(화살표 키, rail을 누른 것) 에는 이동하고, 손가락 아래에서는 전혀 이동하지 않습니다. 포인터를 향해 서서히 따라가 봐야 지연으로만 읽힙니다.
표식을 그리는 방법
위 규칙은 빈틈을 하나 남깁니다. 체크 표시와 라디오 점은 없다가 생기는 것인데, 둘 다 확대해서 등장시킬 수 없습니다. 그래서 갖다 놓지 않고 그립니다.
- 체크 표시는 자기 경로를 따라 그려집니다. 획을 자기 길이만큼의 점선으로 만들고 딱 그만큼 밀어내면, 표식이 얼마나 존재하는지가 0과 1 사이의 숫자 하나가 됩니다. CSS에서는
stroke-dashoffset이고 Flutter에서는 경로를 재서 잘라낸 것입니다. 최종 위치가 아닌 곳에는 아무것도 나타나지 않습니다. - 라디오 점은 고리 한가운데에서 자랍니다. 고리는 고정 크기 자식을 가운데 두므로 너비와 높이가 같은 점을 중심으로 배치됩니다. 상자가 커질 뿐
transform은 쓰지 않고, 고리 바깥의 무엇도 이 때문에 움직이지 않습니다.
표식은 없는 동안에도 문서에 남아 크기 0으로 있습니다. 값이 지워지는 순간 버려지는 표식은 되돌아 나갈 수 없고, 한쪽 방향은 애니메이션이고 반대쪽은 잘라내는 컨트롤은 같은 클릭으로 두 가지를 말하는 컨트롤입니다.
눌림은 칠이 아니라 빛
채움은 그러데이션이고 그러데이션은 transition되지 않습니다. 그래서 호버와 눌림은 filter: brightness()입니다.
hover brightness(1.05) + elevation 한 단계 위, tint가 퍼짐
active brightness(0.95) + 한 단계 아래, 그 밑에서 tint가 오므라듦세 가지가 함께 움직이고, 셋 다 같은 말을 합니다. 컨트롤이 내려갔고 그 아래에 자기 그림자가 들어갈 자리가 줄었다.
하나의 duration, 양방향 동일
--plass-duration은 150ms이고 --plass-ease는 하나의 곡선이며, 양방향에 똑같이 적용됩니다. 내려가는 컨트롤과 올라오는 컨트롤은 같은 스프링입니다. 비대칭한 눌림은 다른 재질의 것입니다.
두 번째 duration
--plass-duration-slow는 260ms이고, 표면이 둘 중 무엇을 쓸지는 딱 하나가 정합니다. 페이지를 가져가는가, 컨트롤에 매달리는가.
- 페이지를 가져감: modal, drawer, overlay, command palette. 시트와 그 아래 scrim이 둘 다 느린 duration으로 사라지고 나타나므로 한 덩어리로 도착합니다. 창 전체에 걸친 150ms는 페이드가 아니라 블러가 살짝 묻은 컷이고, 이렇게 통째로 바뀌는 화면이 그 속도로 바뀌면 읽는 사람은 무엇이 움직였는지 찾게 됩니다.
- 컨트롤에 매달림: menu, popover, tooltip, select와 combobox의 목록, date picker의 시트. 이쪽은 150ms 그대로입니다. 자기가 나온 컨트롤만 한 크기이고, 읽는 사람은 대개 그 안의 무언가로 가는 중이기 때문입니다.
높이가 움직이는 시간도 같은 260ms입니다. accordion, collapsible, pill이 그렇고, 이유도 같습니다. 움직이는 것이 그 아래의 페이지이기 때문입니다.
빛은 포인터와 함께 도착합니다
.plass-glow는 상호작용 가능한 모든 컨트롤 위의 두 겹입니다.
::before는 bloom: 포인터를 중심으로 한 부드러운 원형 빛. 포인터가 들어오면 240ms에 걸쳐 나타나고, 표면 위를 따라 움직입니다.::after는 눌림: 같은 모양을 한 단계 밝게, 들어올 때0ms, 나갈 때 약 700ms. 클릭한 프레임에 빛이 앉고, 손가락이 떨어진 뒤에도 한 박자 동안 눈에 띄게 빠져나갑니다.
둘 다 --p-mx / --p-my를 읽고, 컴포넌트가 pointermove에서 그 값을 요소의 inline style에 직접 씁니다.
이걸 React state에 담지 마세요. 이벤트는 포인터 속도로 발생하므로 setState는 마우스가 움직일 때마다 트리를 다시 렌더링합니다. 좌표는 getBoundingClientRect()가 아니라 offsetX/offsetY에서 오므로 reflow를 강제하지 않습니다. 아이콘에는 pointer-events: none이 걸려 있어 offset은 언제나 컨트롤 기준입니다.
이것이 정적인 specular highlight를 대신했고, 그 이유는 기억해 둘 값어치가 있습니다. 항상 켜져 있는 highlight는 화면 밖 어딘가의 램프에 대한 주장이고, 그래서 래커칠처럼 읽힙니다. 포인터와 함께 도착하는 빛은 포인터에 대한 주장이고, 그건 사실입니다.
::after 겹은 hover가 아예 없는 터치 화면에서 이 효과를 지탱하는 쪽이기도 합니다. 손가락이 닿아 있는 동안 :active가 유지되고 pointermove가 좌표를 계속 쓰므로, 컨트롤 위를 끄는 손가락을 빛이 따라갑니다.
두 색 slot은 variant를 따라 바뀝니다. 흰빛은 거의 흰 시트 위에서 보이지 않기 때문입니다. 채워진 컨트롤은 --plass-glow-on-fill(흰색 18%)을, glass나 ghost는 자기 계열의 soft tint를 씁니다.
6. 상태
세 상태는 각자 자기 축에서 말해야 하고, 각각 기본 상태와 한눈에 구별되어야 합니다.
| 상태 | 어떻게 표현하는지 | 왜 |
|---|---|---|
disabled | opacity: 0.5와 saturate(0.35), 빛도 그림자도 없음 | 반투명 시트로 만든 페이지에서, 페이지가 통과해 보이는 표면은 물체이기를 그만둔 것입니다 |
loading | 그대로, startIcon 자리에 스피너 | 사용 불가가 아니라 진행 중입니다 |
readOnly | 색은 유지, 평평해지고 saturate(0.55) | 컨트롤 모양을 한 라벨입니다 |
네이티브 disabled 속성을 쓰는 것은 disabled뿐입니다. loading과 readOnly는 aria-disabled로 표시되고 focus를 유지하며, 핸들러에서 활성화를 막습니다.
여기서 opacity는 실제로 일을 합니다.
opacity: 0.5에 대한 흔한 비판은 상태와 무관하게 "흐릿함"으로 읽힌다는 것이고, 불투명한 페이지에서는 실제로 그렇습니다. 이 페이지에서는 페이지가 컨트롤을 통과해 오는 것으로 읽히는데, 그건 구체적인 말이고 그 말을 하는 상태는 하나뿐입니다.disabled만 쓰는 축이고 다른 상태는 건드리지 않습니다.
7. 구현 규칙
상태 분기는 CSS가 아니라 JS에서
같은 specificity가 붙은 두 Tailwind variant는 생성된 스타일시트에서의 순서로 결정됩니다. 컴포넌트가 기대도 되는 성질이 아닙니다.
// 이렇게
disabled ? disabledClasses[variant] : readOnly ? readOnlyClasses[variant] : restClasses[variant];
// 이렇게 말고 — data-disabled:와 data-readonly:의 우선순위는 정의되어 있지 않습니다
('data-disabled:opacity-50 data-readonly:saturate-50');색 slot은 inline style로
Tailwind는 소스에 문자 그대로 나타난 클래스 이름만 봅니다. [--p-fill:var(--plass-primary-fill)]을 계열마다 하드코딩하면 색을 하나 추가할 때마다 클래스가 수십 개씩 늘어납니다. 대신 --p-* slot을 inline style로 생성하세요.
'--p-fill': `var(--plass-${color}-fill)`;Tailwind 바깥으로 나가는 이유는 이 종류뿐입니다. Tailwind가 표현할 수 없을 때만 바깥으로 나가세요.
파생 토큰은 테마 root마다 반복합니다
custom property는 자기 var()를 그것이 선언된 요소에서 풉니다. --plass-primary-tint는 테마별 값인 --plass-tint-strength를 읽으므로, :root에만 선언하면 .dark 하위 트리 안에서도 라이트 테마 값으로 굳습니다. 파생 블록의 selector가 :root, .dark, .light, [data-theme='dark'], [data-theme='light']인 이유입니다.
포털의 쌓임 순서
흐름을 벗어나는 표면(modal, drawer, menu, select의 목록, popover, tooltip, toast)은 전부 var(--plass-z-portal) 높이에 그려집니다. 기본값은 50이고, 페이지가 한 줄로 바꿀 수 있습니다. 무엇이 무엇 위에 뜨는지는 앱이 dialog를 꺼내기 전에 이미 정해 둔 문제입니다. 헤더가 있거나, 쿠키 배너가 있거나, 비디오 플레이어가 있고, 여기서 고른 숫자는 남이 세운 사다리에 대한 추측일 뿐입니다.
전부 같은 토큰을 읽는 것도 의도입니다. 서로 다른 값을 주는 순간, modal 안에서 연 select가 modal 뒤로 들어갑니다.
이 토큰은 맨 :root에만 선언하고 어느 테마 블록에도 반복하지 않습니다. 바로 위 규칙의 예외이고, 그 규칙이 있는 이유와 같은 이유입니다. 테마 블록은 자기가 가진 것을 다시 선언하므로, 그 안에 들어간 토큰은 테마 클래스를 단 중첩 요소마다 소비자가 정한 값을 되돌려 버립니다.
읽히지 않게 되면 CSS로 옮깁니다
.plass-glow가 Tailwind utility 묶음이 아니라 styles.css의 실제 클래스인 이유는 [&::before]:[background:radial-gradient(…)]가 기술적으로는 표현 가능하지만 유지할 수 없기 때문입니다. pseudo-element에 그러데이션을 얹는 스타일은 CSS에 속합니다.
outline-none은 절대 쓰지 않습니다
Tailwind v4의 outline-* utility는 스타일을 --tw-outline-style을 통해 지정합니다. 요소 어딘가의 outline-none이 그 변수를 none으로 만들면 focus ring이 통째로 사라집니다. shorthand를 쓰세요.
focus-visible:[outline:2px_solid_var(--p-ring)] focus-visible:[outline-offset:0px]ring이 놓이는 자리
offset은 0이고, 라이브러리의 모든 컨트롤에서 0입니다. 자기 테두리가 붙은 컨트롤(field, select, tick, switch)에서 ring을 2px 띄우면 하나의 사물 주위에 사각형이 세 겹으로 읽히고, 사물이 자기 ring에서 떨어져 나온 것처럼 보입니다. 붙이면 outline이 테두리 바깥면에 그대로 닿고, 테두리가 두꺼워지면서 그 색 계열을 입은 것으로 읽힙니다.
테두리가 없는 컨트롤에서도 잃는 것은 없습니다. outline은 언제나 border box 바깥에 그려지므로, 채워진 키에서는 칠 위에 겹친 띠가 아니라 페이지를 배경으로 한 테두리가 됩니다.
예외는 다른 것이 잘라내는 컨트롤 하나뿐입니다. 레일 위의 tab, 홈 안의 segment, 둥근 시트 안의 행, 금이 그어진 판 안의 accordion 헤더. 이들은 focus-visible:[outline-offset:-2px]을 씁니다. 바깥에 그린 ring은 위나 아래가 잘려 나가기 때문입니다.
ring은 outline
Tailwind의 ring-*은 box-shadow이고, Plass의 모든 표면은 이미 box-shadow를 elevation과 tint, 그리고 유리의 hairline에 쓰고 있습니다. ring을 쓰려면 세 variant 각각의 chain에 끼워 넣어야 하고, 처음으로 그걸 빠뜨린 variant는 조용히 focus ring을 잃게 됩니다.