같은 버튼이라도 클 때와 작을 때, 강조할 때와 물러설 때, 손이 얹혔을 때와 못 누를 때가 다 다릅니다. 이 차이를 버튼마다 새로 만들지 않고 변형과 상태로 체계 있게 관리하는 코드를 짚어 보겠습니다. 아래는 그대로 복사해 확인할 수 있습니다.
40.1 변형이 갈아 끼울 지역 변수부터
기본 버튼은 겉모습을 지역 변수로 열어 둡니다. 변형은 이 변수만 바꿔 끼우면 되니, 규칙 하나로 여러 모습이 나옵니다.
.btn {
--btn-bg: #2563eb;
--btn-fg: #ffffff;
--btn-pad-y: 8px;
--btn-pad-x: 16px;
--btn-size: 15px;
display: inline-flex;
align-items: center;
padding: var(--btn-pad-y) var(--btn-pad-x);
border: 1px solid transparent;
border-radius: 8px;
background: var(--btn-bg);
color: var(--btn-fg);
font-size: var(--btn-size);
cursor: pointer;
}
색과 여백과 글자 크기를 값으로 박지 않고 변수에 담아 둔 게 핵심입니다. 변형은 이 변수를 갈아 끼우는 스위치일 뿐입니다.
40.2 색 변형은 변수만 바꾼다
위험한 동작을 알리는 빨강, 배경 없이 물러선 고스트를 색 변형으로 만듭니다. 두 밑줄 뒤에 변형 이름을 붙이는 BEM 방식으로 이름을 짓습니다.
.btn--danger {
--btn-bg: #dc2626;
}
.btn--ghost {
--btn-bg: transparent;
--btn-fg: #2563eb;
border-color: #2563eb;
}
<button class="btn">확인</button>
<button class="btn btn--danger">삭제</button>
<button class="btn btn--ghost">취소</button>
세 버튼이 같은 뼈대를 공유하며 색만 다릅니다. 새 색이 필요하면 한두 줄짜리 변형을 하나 더 얹으면 그만입니다.
40.3 크기 변형도 같은 방식
크기는 색과 겹치지 않는 다른 축입니다. 여백과 글자 변수만 건드리니 어떤 색 변형과도 자유롭게 짝지을 수 있습니다.
.btn--sm {
--btn-pad-y: 4px;
--btn-pad-x: 10px;
--btn-size: 13px;
}
.btn--lg {
--btn-pad-y: 12px;
--btn-pad-x: 22px;
--btn-size: 17px;
}
<button class="btn btn--danger btn--sm">작은 삭제</button>
<button class="btn btn--ghost btn--lg">큰 취소</button>
색 변형과 크기 변형을 한 버튼에 겹쳐 얹어도 서로 부딪히지 않습니다. 축을 나눠 두면 조합이 곱셈으로 늘어도 코드는 덧셈으로만 늘어납니다.
40.4 손이 닿는 순간의 상태
변형이 정적인 모습이라면 상태는 순간순간의 반응입니다. 손을 올린 :hover, 누르는 :active를 얹어 버튼이 살아 있게 합니다.
.btn:hover {
filter: brightness(0.94);
}
.btn:active {
transform: translateY(1px);
}
filter로 살짝 어둡게만 하면 색 변형이 무엇이든 알아서 짙어집니다. 빨강은 짙은 빨강으로, 파랑은 짙은 파랑으로, 상태 규칙 하나가 모든 색을 감당합니다.
색이 부드럽게 넘어가도록 전환을 얹되, 움직임에 민감한 사용자에게는 전환을 꺼 줍니다. 배려도 부품 안에 함께 심어 둡니다.
.btn {
transition: filter .15s ease, transform .1s ease;
}
@media (prefers-reduced-motion: reduce) {
.btn {
transition: none;
}
}
손을 올리면 색이 스르르 짙어지고 누르면 살짝 내려앉습니다. 움직임을 줄여 달라는 설정이 켜져 있으면 이 전환은 조용히 사라집니다.
40.5 못 누르는 상태를 두 갈래로
진짜 <button>은 disabled 속성으로 잠기고, div 같은 요소로 만든 버튼은 aria-disabled로 잠급니다. 둘 다 같은 모습이 되도록 선택자를 묶어 줍니다.
.btn:disabled,
.btn[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
흐려진 색과 금지 커서로 지금은 못 누른다고 알립니다. aria-disabled까지 챙긴 덕에 화면 낭독기를 쓰는 사용자도 이 버튼이 잠겼다는 사실을 전해 받습니다.
40.6 키보드 사용자를 위한 포커스
마우스로 누를 땐 필요 없지만 키보드로 이동할 땐 지금 어디에 있는지가 보여야 합니다. :focus-visible는 딱 그 경우에만 외곽선을 켜 줍니다.
.btn:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 2px;
}
마우스 클릭에는 조용하고 키보드 이동에는 또렷한 테두리가 뜹니다. 포커스 표시를 지워 버리면 키보드만 쓰는 사람은 길을 잃으니, 지우지 말고 다듬는 쪽이 옳습니다.
40.7 눌린 채 머무는 토글 버튼
알림 켜기처럼 켜짐과 꺼짐을 오가는 버튼은 지금 켜졌는지를 상태로 남겨야 합니다. aria-pressed 속성이 그 값을 나르고, 스타일은 그 속성을 읽어 반응합니다.
.btn[aria-pressed="true"] {
--btn-bg: #1e3a8a;
box-shadow: inset 0 1px 3px rgba(0,0,0,.3);
}
<button class="btn" aria-pressed="false">알림</button>
aria-pressed 값이 true가 되면 색이 짙어지고 안쪽 그림자로 눌린 느낌이 남습니다. 이 속성은 겉모습만이 아니라 화면 낭독기에 지금 켜짐이라는 뜻까지 함께 전합니다.
40.8 :is()와 :where()로 규칙 묶기
여러 변형에 같은 규칙을 걸 때 선택자를 길게 늘어놓으면 읽기 어렵습니다. :is()로 묶으면 한 줄로 정리되고, :where()는 우선순위를 0으로 눌러 덮어쓰기를 쉽게 합니다.
.btn:is(.btn--danger, .btn--ghost):hover {
filter: brightness(0.9);
}
:where(.btn--sm, .btn--lg) {
letter-spacing: 0.2px;
}
:is()는 안에 든 어느 하나라도 맞으면 걸리니 danger와 ghost를 한 규칙으로 다룹니다. :where()는 힘이 0이라 나중 규칙이 손쉽게 이깁니다.
40.9 조합 폭발을 막는 정리
색 세 가지에 크기 세 가지에 상태 네 가지를 낱낱이 클래스로 만들면 수십 개가 필요합니다. 하지만 변형은 지역 변수를 갈아 끼우고 상태는 변수 위에서 반응하게 하면, 코드는 색과 크기와 상태를 따로 더한 만큼만 늘어납니다. 축을 나눠 각 축을 독립으로 두는 것, 이것이 조합이 곱으로 불어나도 코드를 합으로 붙드는 비결입니다.