[HTML/CSS] 터치 기기에는 hover가 없다

링크를 누르면 회색 사각형이 번쩍이고, 드롭다운 메뉴는 한 번 누르면 계속 열린 채로 남고, 테두리를 지워버린 탓에 키보드로는 어디에 있는지 알 수 없다.

증상은 여러 가지인데 원인은 하나다. 터치 기기에는 hover라는 상태가 없다. 마우스 커서는 클릭하지 않고 요소 위에 머무를 수 있지만, 손가락은 닿는 순간이 곧 누르는 순간이라 머무는 상태가 없다. 그래서 브라우저가 탭을 hover처럼 처리해주는데, 마우스를 전제로 쓴 CSS는 여기서 엉뚱하게 동작한다.

▍탭할 때 생기는 회색 사각형

모바일에서 링크나 버튼 영역을 누르면 원하지 않는 회색 사각형이 번쩍인다. 브라우저가 “여기를 눌렀다”를 알려주려고 넣는 것이다.

a,
button {
  -webkit-tap-highlight-color: transparent;
}

rgba(0, 0, 0, 0)을 써도 같다. 이 속성을 :focus 안에 넣으면 동작하지 않으니 요소 자체에 걸어야 한다.

접두어가 붙어 있어서 사파리 전용처럼 보이지만 안드로이드 크롬에서도 동작한다.

▍사라지지 않는 hover

아이폰이나 아이패드에는 마우스가 없으니 hover 효과가 제대로 동작하지 않는다. 드롭다운 메뉴처럼 hover로 펼치는 UI가 특히 문제인데, 한 번 누르면 계속 열린 상태로 남고 화면의 다른 곳을 눌러야 겨우 닫힌다.

2020년에는 이렇게 해결했다.

<body ontouchstart=""></body>

body에 빈 ontouchstart를 붙이면 사파리가 터치를 다르게 처리하면서 hover가 풀린다. 되긴 되지만 마크업에 아무 일도 하지 않는 이벤트 핸들러를 심어두는 방식이라 지금은 권하지 않는다.

지금은 미디어 쿼리로 조건을 나누는 것이 답이다.

/* hover를 실제로 지원하는 포인터가 있을 때만 적용한다 */
@media (hover: hover) {
  .menu:hover .submenu {
    display: block;
  }
}

hover: hover는 주 입력 장치가 hover를 지원할 때만 매치된다. 터치 기기에서는 이 블록이 아예 적용되지 않으니 남아 있을 hover 상태도 없다.

그러면 터치에는 무엇을 줄지 정해야 한다. 버튼에 hover만 걸어두면 마우스에서는 반응이 보이는데 손가락에서는 아무 일도 일어나지 않는 것처럼 느껴진다. :active는 터치에서도 동작하니 눌리는 느낌은 이쪽으로 준다.

.button:active {
  transform: scale(0.98);
}

@media (hover: hover) {
  .button:hover {
    background: var(--hover-bg);
  }
}

hover는 “여기를 누를 수 있다”를 미리 알려주는 신호고, active는 “지금 눌렸다”를 알려주는 신호다. 마우스에서는 둘 다 쓸 수 있지만 터치에는 뒤쪽만 있으니, 앞쪽에만 의존하는 UI는 터치에서 통째로 사라진다. 드롭다운을 hover로 펼치던 메뉴가 모바일에서 못 쓰게 되는 이유도 결국 같다.

기기가 두 부류로 깔끔하게 갈리지는 않는다.

기기hoverpointer
마우스를 쓰는 데스크톱hoverfine
손가락만 쓰는 폰과 태블릿nonecoarse
터치스크린이 달린 노트북hoverfine

hoverpointer는 주 입력 장치만 본다. 그래서 터치스크린 노트북은 트랙패드가 주 장치라 hover: hover에 걸리고, 손가락으로도 쓸 수 있다는 사실은 여기에 나타나지 않는다. 보조 장치까지 보려면 any-hoverany-pointer를 쓰는데, 대개는 hover: hover 하나로 충분하다.

▍초점 표시와 outline: none

영역을 누를 때 테두리가 생기는 경우가 있어서 이렇게 지우곤 했다.

a:focus {
  outline: none;
}

이렇게 하면 마우스로 눌렀을 때의 테두리는 사라지는데, 키보드로 이동할 때의 초점 표시까지 같이 사라진다. Tab 키로 페이지를 쓰는 사람은 자기가 지금 어디에 있는지 알 수 없게 된다.

:focus-visible을 쓰면 두 경우가 갈린다.

a:focus {
  outline: none;
}

a:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

:focus-visible은 브라우저가 초점 표시를 보여줄 만하다고 판단할 때만 매치된다. 마우스 클릭이나 화면 탭에서는 걸리지 않고 키보드 이동에서는 걸린다. 초점 표시를 통째로 지우는 대신 필요한 곳에만 남기는 것이다.

▍제스처와 길게 누르기

터치에서만 걸리는 것이 두 개 더 있다.

이미지나 버튼을 길게 누르면 텍스트가 선택되거나 컨텍스트 메뉴가 뜬다.

.tappable {
  -webkit-user-select: none;
  user-select: none;
}

그리고 스와이프로 조작하는 컴포넌트를 만들면 브라우저의 스크롤이 먼저 먹어버린다. 이때는 어느 방향을 브라우저가 가져갈지 정해준다.

.carousel {
  touch-action: pan-y; /* 세로 스크롤은 브라우저에, 가로는 내가 처리한다 */
}

touch-action: none으로 전부 가져올 수도 있지만 페이지 스크롤까지 막히니, 필요한 방향만 남기는 편이 안전하다.

touch-action은 다른 데에도 쓴다. 모바일 브라우저는 탭이 들어오면 더블탭 확대가 이어질지 잠깐 기다렸다가 클릭을 넘겨주던 시절이 있었고, 그래서 버튼이 한 박자 늦게 반응했다. 요즘은 width=device-width 뷰포트를 쓰면 대부분 사라졌지만, 확대할 이유가 없는 요소에는 걸어두면 확실하다.

button,
a {
  touch-action: manipulation; /* 더블탭 확대를 포기하고 즉시 반응한다 */
}

▍터치를 기준으로 다시 보기

  • 탭 하이라이트는 -webkit-tap-highlight-color로 끈다
  • hover 규칙은 @media (hover: hover)로 감싼다
  • 초점 표시는 지우지 말고 :focus-visible로 경우를 나눠준다
  • 제스처는 touch-action으로, 길게 누르기는 user-select로 처리한다

미디어 쿼리로 hover 지원 여부를 먼저 확인하고, 지원하는 기기에만 hover 규칙을 적용한다. 마우스를 기준으로 만든 뒤 터치에서 어긋나는 것을 지우는 대신, 기기를 먼저 나누고 각각에 맞는 규칙을 걸어주는 것이 좋다.

참고