버튼은 작은 무대에 오른 배우입니다. 가만히 있을 때와 손이 다가올 때와 눌리는 순간, 저마다 다른 얼굴을 지어야 하지요. 그 표정들을 CSS로 하나씩 그려 보겠습니다.

78.1 부드러운 전환의 바탕

여러 표정 사이를 오가는 전환이 툭툭 끊기면 버튼이 값싸 보입니다. 모든 상태 변화의 바탕에 짧고 자연스러운 전환 곡선을 하나 깔아 둡니다.


.btn {
background: #2563eb;
color: #fff;
padding: 11px 20px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background .18s cubic-bezier(.2, .8, .2, 1),
box-shadow .18s cubic-bezier(.2, .8, .2, 1),
transform .1s ease;
}


일정 속도인 linear 대신 시작과 끝을 눌러 주는 cubic-bezier 곡선을 쓰면, 색과 그림자가 딱딱하게 튀지 않고 스르르 넘어갑니다. 이 짧은 부드러움 하나가 버튼의 격을 은근히 끌어올립니다.

78.2 호버: 떠오르는 표정

마우스가 다가오면 배경이 짙어지고 그림자가 깊어지며 버튼이 살짝 떠오릅니다. 여기를 누르면 무언가 일어난다고 미리 귀띔하는 표정이지요.


.btn:hover {
background: #1d4ed8;
box-shadow: 0 6px 16px rgba(37, 99, 235, .35);
transform: translateY(-2px);
}


그림자가 아래로 길어지고 몸체가 2px 떠오르면, 버튼이 종이에서 살짝 들린 것처럼 보입니다. 다만 이 반응은 절제가 생명이라, 화면이 번쩍이지 않을 만큼만 톤을 바꾸는 정도가 오래 질리지 않습니다.

78.3 눌림과 키보드와 잠금

누르는 순간에는 즉각성이 생명입니다. 손끝이 닿는 찰나에 버튼이 안으로 들어가면, 명령이 받아들여졌다고 곧바로 안심하지요.


.btn:active {
transform: translateY(1px) scale(.98);
box-shadow: 0 2px 6px rgba(37, 99, 235, .3);
}
.btn:focus-visible {
outline: 3px solid #93c5fd;
outline-offset: 2px;
}
.btn:disabled {
background: #93c5fd;
box-shadow: none;
cursor: not-allowed;
transform: none;
}


눌리면 떠올랐던 몸이 도로 내려앉고, 탭 키로 옮겨 오면 또렷한 링이 지금 여기를 짚어 줍니다. 잠긴 버튼은 흐려진 채 눌림 반응까지 none으로 막아, 지금은 못 누른다는 사실을 정직하게 전합니다.

78.4 물결이 퍼지는 반응

손끝이 닿은 지점에서 물결이 번지듯 응답이 시작되면, 자기 행동과 화면의 응답이 한 몸으로 이어져 있다고 느낍니다. 가짜 요소 하나를 눌림에 맞춰 부풀립니다.


.ripple {
position: relative;
overflow: hidden;
}
.ripple::after {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, .6);
opacity: 0;
transform: scale(1);
transition: transform .5s ease-out, opacity .5s ease-out;
}
.ripple:active::after {
transform: scale(28);
opacity: 1;
transition: 0s;
}


누르는 순간 작은 점이 scale(28)로 순식간에 부풀어 버튼을 채우고, 손을 떼면 반 초에 걸쳐 옅어지며 사라집니다. overflow: hidden이 물결을 버튼 모서리 안에 가둬, 둥근 테두리 밖으로 삐져나오지 않게 합니다.

78.5 로딩중인 버튼

제출을 누른 뒤 결과를 기다리는 시간이 걸린다면, 버튼 안에 작은 스피너를 돌려 지금 처리 중임을 알립니다. 이 잠금은 같은 요청이 여러 번 날아가는 사고도 함께 막지요.


@keyframes spin {
to {
transform: rotate(360deg);
}
}
.btn[aria-busy="true"] {
color: transparent;
pointer-events: none;
}
.btn[aria-busy="true"]::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border: 2px solid rgba(255, 255, 255, .5);
border-top-color: #fff;
border-radius: 50%;
animation: spin .6s linear infinite;
}


aria-busy="true" 하나로 글자를 숨기고 스피너를 띄우며 클릭까지 막습니다. 상태와 겉모습을 이렇게 한 속성에 묶어 두면 코드가 정갈해지고, 화면 낭독기를 쓰는 사용자에게도 처리 중이라는 사실이 함께 전해집니다.

78.6 위계와 아이콘 버튼

가장 하고 싶은 행동을 담은 주된 버튼은 색이 꽉 차게, 물러서는 버튼은 얌전하게 만들어 사용자가 갈 길을 조용히 안내합니다. 모든 버튼이 똑같이 소리치면 어디로 가야 할지 오히려 헷갈리니까요.


.btn-secondary {
background: transparent;
color: #2563eb;
border: 1px solid #c7d2fe;
}
.btn-secondary:hover {
background: #eff6ff;
box-shadow: none;
transform: none;
}


주된 버튼이 또렷한 호버와 눌림으로 손을 부르는 동안, 보조 버튼은 테두리만 남긴 채 차분하게 물러섭니다. 한편 아이콘만 담은 버튼은 눈에 보이는 크기와 별개로 눌리는 판을 넉넉히 잡아 줍니다.


.icon-btn {
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
}


아이콘 자체는 20px라도 눌리는 영역을 44px로 키우면, 손끝이 조금 어긋나도 제대로 눌립니다. 모바일에서 옆 버튼이 잘못 눌리는 사고가 확 줄어드는 지점입니다.

78.7 움직임을 줄여야 하는 사용자

떠오르고 물결치고 도는 반응이 어떤 사용자에게는 어지럼이 됩니다. 상태를 알리는 색은 그대로 두되, 움직임만 걷어 내는 스위치를 함께 답니다.


@media (prefers-reduced-motion: reduce) {
.btn,
.btn-secondary {
transition: background .18s ease;
}
.btn:hover,
.btn:active {
transform: none;
}
.ripple::after {
transition: none;
}
.btn[aria-busy="true"]::before {
animation: none;
}
}


움직임 줄이기를 켠 사람에게는 떠오름과 물결과 스피너의 회전이 사라지고, 색으로 전하는 상태만 남습니다. 반응을 지우는 것이 아니라 흔들림만 덜어 내는 것이 핵심입니다.

78.8 정리

버튼은 단순한 네모가 아니라 여러 표정을 지닌 작은 배우입니다. 부드러운 전환을 바탕에 깔고, 호버로 떠오르고, 눌림에 즉각 답하고, 키보드에 길잡이를 주고, 잠금과 로딩을 정직하게 알리고, 위계로 길을 안내하는 것. 이 표정들을 빠짐없이 그리고 매끄럽게 이어 주면 버튼은 비로소 살아 움직입니다.