카드 컴포넌트의 ‘글리치’ 나는 hover 상태를 고치는 김에, 의미론, 접근성, 상호작용까지 함께 개선하는 방법을 다룹니다. CSS 트랜지션 글리치의 원인, 버퍼 존을 만드는 가상 요소(::after), ‘break out’ 링크 패턴 등 실전 팁을 예제로 설명합니다.
가령 우리에게 어떤 제품 카드의 “얼기설기한(janky)” hover 상태를 고치라는 티켓이 배정되었다고 해보자. 카드는 멋진 트랜지션과 함께 몇 픽셀 위로 이동하지만, 포인터가 카드의 하단에 있을 때는 글리치가 난다.
멋진 transform과 transition을 만들 때 생길 수 있는 문제는, 대부분의 시간 기반 효과가 :hover에서 요소의 경계를 포인터가 벗어나는 순간 사용자에게 글리치하고 울퉁불퉁한 경험을 준다는 점이다.
직접 확인해 보자.
이 효과를 만들어내는 CSS는 다음과 같다.
.card:hover {
transform: translateY(calc(var(--transform-size) * -1));
transition: transform 150ms linear;
}
우리는 --transform-size 커스텀 프로퍼티를 가지고 있고, calc()로 -1을 곱해 음수 값으로 만들면 카드 요소가 위로 이동한다. 선형 전이 타이밍 함수(linear transition timing function)는 그 효과를 부드럽게 한다.
선택자 .card:hover는 활성 호버 중인 특정 .card 요소를 타깃팅하기 때문에 포인터가 .card를 떠나는 순간 기본 상태로 급히 되돌아가며, 글리치가 반복되는 루프를 만든다.
먼저 카드 요소를 위한 CSS를 살펴보자.
:root {
--gutter: var(--space-m) var(--space-s);
--card-padding: var(--space-s);
--radius: var(--space-xs);
--transform-size: var(--space-xs);
}
.card {
display: block;
padding: var(--card-padding);
font-size: var(--size-step-00);
text-decoration: none;
color: var(--color-dark);
background: var(--color-light-shade);
border-radius: calc(var(--radius) + var(--card-padding));
}
:root에 커스텀 프로퍼티로 토큰들을 설정했기 때문에, 이 값들은 CSS 전역에서 사용할 수 있다. 그런 다음 이 값들로 일관된 패딩, 간격, 반경을 지정한다. 색상 커스텀 프로퍼티는 우리의 데모 기본 스타일에서 가져온다.
한 가지 접근은 .card의 부모(우리 예시에서는 <li> 요소)에 padding을 적용하는 것이다.
:has(> .card) {
padding: var(--card-padding);
}
:has(> .card):hover {
transform: translateY(calc(var(--transform-size) * -1));
transition: transform 150ms linear;
}
보시다시피 문제는 전체적으로 가로 정렬 문제가 생긴다는 점이다. 물론, --card-padding에 -1을 곱해 음수 마진으로 이를 고치려 시도할 수 있다.
.grid:has(.card) {
margin-inline-start: calc(var(--card-padding) * -1);
}
이렇게 하면 시각적으로 정렬은 맞지만 — 그리드 컨테이너에 배경을 넣어둔 것을 보면 알 수 있듯 — 말 그대로 숨은 ‘반창고’ 같은 임시방편이 생긴다. 내 생각엔 이제부터 해키한 영역으로 들어가는 셈이다.
우리의 hover 상태는 .card 요소에 붙어 있다. 그런데 이 요소는 비어 있지 않다. 예를 들어 .card의 <img /> 위에 호버해도, 그것이 .card의 자식 요소이기 때문에 .card:hover 상태가 여전히 트리거된다.
이를 염두에 두면, 카드를 ‘삐져나오게(bleed out)’ 해서 일종의 버퍼 존을 만드는 것이 합리적이다.
가상 요소로 해보자.
.card {
position: relative;
}
.card::after {
content: "";
display: block;
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
width: 100%;
height: calc(100% + var(--transform-size));
/* 데모용 */
background: red;
opacity: 0.4;
}
::after 가상 요소를 절대 위치의 블록 요소로 만들면, 좌상단에 고정시킨 다음, 100% 높이에 --transform-size를 더해 가상 요소가 부모를 조금 더 넉넉하게 채우도록 만들 수 있다.
이 가상 요소는 절대 위치이기 때문에 카드보다 커져도 형제 요소들에는 영향을 주지 않는다. 유용하다.
우리는 분명 올바른 방향으로 가고 있지만 그래도 글리치가 있다. 100% 높이에 비교적 작은 --transform-size만 더했기 때문에 버퍼 존 측면에서 여유가 별로 없다. 이를테면 손이 좀 떨리는 사람에게는 이상적이지 않다.
이 버퍼 간격을 제어하기 위한 전용 커스텀 프로퍼티를 만들자.
:root {
/* 다른 커스텀 프로퍼티들 */
--transform-space: var(--space-m);
}
새로운 --transform-space 커스텀 프로퍼티는 기존 스페이스 스케일을 사용해 훨씬 너그러운 크기다. 물론 원하는 값을 쓰면 된다.
이제 해야 할 일은 커스텀 프로퍼티만 바꾸는 것이다.
.card::after {
/* 나머지 CSS */
height: calc(100% + var(--transform-space));
}
이왕 하는 김에 카드의 시맨틱도 개선하자고정 링크이 티켓을 집었을 때 시맨틱이 그다지 ‘이상적’이지 않다는 걸 눈치챘다. 함께 HTML 마크업을 보자.
<a href="#" class="card flow">
<img alt="A dark grey Nike trainer shoe with a lightweight mesh upper, a black speckled shoelace and a thick, textured sole" src="https://assets.codepen.io/174183/card-product-1.jpg?width=1500&format=auto&quality=80" />
<p class="card__heading">Nike trainers</p>
<p class="card__price">£59.99 - £79.99</p>
</a>
카드는 <a> 요소이며, 그 안에 <img />와 두 개의 <p> 요소가 있다.
끔찍하다고까지 할 것은 아니지만, Heydon Pickering이 Inclusive Components에서 말했듯, 다른 콘텐츠를 감싼 링크(<a>)인 카드를 참고하면 다음과 같다.
…그래서 스크린 리더가 이를 만났을 때, 이렇게 안내할 수 있다. “Card design woes, 카드 컴포넌트를 디자인할 때 피해야 할 10가지 흔한 함정, 작성자 Heydon Pickering, 링크”.
이해라는 측면에서 _파국적_이라고까지는 할 수 없지만 장황하다 — 특히 카드가 더 많은 콘텐츠를 담도록 진화한다면 — 특히 그게 인터랙티브 콘텐츠라면 더더욱. 또한 HTML5에서 기술적으로 허용되긴 해도,
<a>같은 인라인 요소 안에<h2>같은 블록 요소가 있는 것은 꽤 예상 밖이다.만약 인터랙티브성을 더하기 시작한다면(예: 작성자 이름에 링크), 상황은 더욱 혼란스러워진다. 일부 스크린 리더는 ‘블록 링크’의 첫 번째 요소만 읽어 장황함은 줄이지만 추가 기능을 놓치기 쉽다. 첫 번째 링크 안에 또 다른 링크가 있으리라고는 기대하지 않기 때문에, 시각장애 사용자는 그 사실을 모른 채 탭으로 지나쳐버릴 수도 있다.
우리에게는 현재 맥락에서는 _링크_로 잘 동작하는 카드가 있지만, 이 요소가 앞으로 어떻게 진화할지 알 수 없기 때문에 장기적인 기술 부채 문제를 은연중에 만들고 있고, 스크린 리더 같은 보조공학을 사용하는 사람들에게 문제를 야기할 가능성도 있다.
스크린 리더 사용자에게 접근 자체가 불가능한 문제는 아니지만, 이상적인 경험을 제공하지 못하므로, 결과적으로 이것은 사용자 경험 문제이기도 하다.
Heydon과 내가 모두 옹호하는 방법으로 이 컴포넌트를 다듬어 보자. 카드 _안_의 단일 링크의 자식으로 ‘break out’ 가상 요소를 두는 방식이다. Heydon은 헤딩 안의 링크로 했고, 나는 버튼 같은 링크를 선호했다.
오늘은 카드 안에 버튼 어포던스가 없으니 Heydon의 접근을 따라가 보자.
먼저 마크업을 수정해야 한다.
<li class="card flow">
<img alt="A dark grey Nike trainer shoe with a lightweight mesh upper, a black speckled shoelace and a thick, textured sole" src="https://assets.codepen.io/174183/card-product-1.jpg?width=1500&format=auto&quality=80" />
<h3 class="card__heading">
<a href="#">Nike trainers</a>
</h3>
<p class="card__price">£59.99 - £79.99</p>
</li>
이제 카드를 자식으로 하는 가상 요소를 만드는 대신, 새 헤딩 링크의 가상 요소로 옮길 수 있다.
상속을 사용해 헤딩의 폰트 처리를 ‘리셋’하자.
.card__heading {
font-size: inherit;
font-weight: inherit;
}
이제 자식 <a>의 밑줄도 제거하자.
.card__heading a {
text-decoration: none;
}
이제 앞서 사용했던 .card::after CSS를 다음 CSS로 교체한다.
.card__heading a::after {
content: "";
display: block;
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
width: 100%;
height: calc(100% + var(--transform-space));
background: var(--psuedo-element-bg, none);
opacity: var(--psuedo-element-opacity, 0);
cursor: pointer;
}
헤딩을 상대 위치의 부모(position: relative)로 만들지 않았다는 점에 주의하자. 이 글에서 설명했듯, 그 가상 요소가 카드 컨테이너 바깥으로 계속 ‘튀어나오게’ 하고 싶기 때문이다.
나는 Complete CSS에서도 비슷한 패턴을 사용해, 자식 버튼 요소에 호버가 되었을 때 부모 카드에 호버 시각 효과를 적용했다. 컴포넌트를 여기서 확인하자.
그 강의에서는 카드 전체를 클릭 가능하게 만들지 않는다. 우리가 레슨에서 하는 작업의 요점은 더 나은 디자인 결과물을 만드는 것이기 때문이다. 내가 보기에 그곳에서 선택한 패턴은 모든 사람의 요구를 충족시키므로, 오늘 다루는 패턴보다 훨씬 낫다.
그 패턴에서는 탭 공간을 넓히기 위해 <img /> 주위를 감싼 링크 하나와, 링크 버튼 하나 — 이렇게 두 개의 링크를 적용했다. 오늘 여기서도 그 기법을 쓸 수 있었겠지만, 내가 실무에서 보는 카드의 _대다수_는 오늘 우리가 다루는 것처럼 전체 영역을 링크로 만드는 방식을 택한다.
팀에서 일이 어떻게 돌아가는지 안다. 이해관계자는 전체를 클릭 가능하게 만들길 원하곤 한다! 오늘 우리가 택한 경로는 적어도 그 경험을 개선해 준다. 작은 승리라도 의미가 있다.
오늘 우리의 일은 호버 상태를 고치는 것이었고, 이제 전반적으로 훨씬 나아졌다. 카드의 호버 상태만 개선한 것이 아니라, 카드 자체도 훨씬 더 잘 리팩터링했다. 여전히 잘한 일이라고 말할 수 있다. 축하 차 한 잔 하자.
팀마다 사정이 다를 수 있다. 이를테면, 꽤 엄격한 애자일 방법론으로 일해 티켓이 미리 정의되고 ‘사이징’되어 있을 수도 있다. 그런 맥락이라면, 작업 범위를 벗어난 변경을 즉시 하기보다는 문제를 요약한 티켓을 새로 만들겠다.
내가 말하고 싶은 요지는, 우리는 무언가를 작업할 때 항상 그것을 개선할 방법을 찾아야 한다는 것이다. 요즘 코드베이스는 크기가 큰 경우가 많아서, 우리가 작업한 후 몇 달 동안 아무도 그 카드를 열어 보지 않을 가능성이 높다.
우리의 일은 단순히 코드를 쓰는 것에 그치지 않는다. 비판적 사고와 분석 역량을 활용해, 텍스트 에디터에 문자를 타이핑하는 범위를 넘어서는 결정을 내리는 것이다. 이러한 사고가 우리를 AI 도구와 구분 짓는다. 이런 종류의 사고는 본질적으로 인간적인 행동이기 때문이다.
그런 기술들이 그 경지에 가까워질 것 같진 않다. 하지만 사람들은 이제 이 기술보다 자신을 더 돋보이게 만들 방법을 찾아야 하는 상황이다 — 보여주기 관점에서 말이다 — 그래서 오늘 우리가 한 방식의 일을 연습하는 것이 좋은 방법이다. 기존 코드를 선제적으로 비판적으로 바라보는 태도는 장기적으로 큰 효율을 만든다. 그런 평판을 얻는 것은 유용하다.
내가 보기엔, 이런 방식은 AI 도구의 대체로 ‘추가만 하는(append-only)’ 방법론을 확실히 능가한다.
이 글이 마음에 드셨나요?Open Collective에서 팁을 남겨 응원해 주세요