Prologue
최근 리뉴얼 프로젝트에 참여해 서비스 전체 레이아웃 및 디자인을 개편하는 작업을 진행했습니다. 기존의 콘텐츠를 세분화하고 메뉴를 확장하며 GNB 구성에도 변화가 있었는데요. 1차 메뉴로만 구성된 단순한 GNB에서 1차-2차 메뉴의 depth로 확장되면서 메뉴 이동 시 유저 경험을 향상하기 위해 safe triangle target area를 적용하게 되었습니다. 이번 글에서는 이것이 무엇인지, 그리고 어떻게 적용했는지에 대한 경험을 소개해보고자 합니다.
👉🏻 2차 메뉴 선택 방식
위 이미지처럼 2차 메뉴에 접근할 때 1차 메뉴를 벗어나 대각선으로 접근한 경험이 있을 거라 생각합니다. 그런데 생각해 보면 타겟이 되는 1차 메뉴에서 마우스가 벗어나면 2차 메뉴가 닫혀야 정상인데 멀쩡하게 이동했습니다. 어떻게 된 걸까요?
기존에 인지하던 유저 플로우를 정리해보면 다음과 같습니다.
- 타겟이 되는 1차 메뉴로 마우스를 이동하며 2차 메뉴가 활성화된다.
- 타겟이 되는 2차 메뉴로 이동하면 1차 메뉴는 선택 상태가 유지된다.
- 다른 1차 메뉴로 마우스를 이동하면 해당 2차 메뉴가 활성화된다.
여기서 세 번째 플로우인 다른 1차 메뉴로 마우스를 이동하면 해당 2차 메뉴가 활성화된다 와 대각선으로 접근한다 는 방식은 분명 맞지 않습니다. 하지만 이 동작이 유효했던 건 바로 safe triangle target area 덕분이었습니다.
🤔 Safe Triangle Target Area란?
'안전한 삼각형 형태의 타겟 영역'으로 해석할 수 있는 이 영역이 바로 대각선 이동의 비밀입니다. Amazon에서 처음 공개한 이 영역은 1차 메뉴에 마우스가 계속 올려져 있는 것처럼 2차 메뉴가 계속 활성화되도록 만들어줍니다. 간단히 아이디어를 정리해 보면 다음과 같습니다.
- 1차 메뉴에 마우스를 올리면 보이지 않는 safe triangle target area가 활성화된다.
- 마우스 위치, 2차 메뉴의 좌측 상단-하단 꼭짓점을 기준으로 삼각형 영역을 생성한다.
- 삼각형 영역까지 포함해 1차 메뉴의 활성화 범위가 포함된다.
- 활성화 범위를 벗어나면 삼각형 영역은 비활성화된다.
- 다른 1차 메뉴로 이동하거나
- 타겟이 되는 2차 메뉴로 이동하거나
🚀 적용 예시
예시는 아래 블로그의 내용을 옮겨 작성했습니다. 아래 '실제 적용 예시'에서는 개선된 적용 방식을 확인하실 수 있습니다.
ref. https://ishadeed.com/article/target-size/
위 방식을 적용하기 위해서는 CSS의 clip-path의 polygon 속성을 사용해야 합니다. clip-path를 사용하면 요소의 가시 영역을 정의할 수 있으며 x, y 좌표를 기준으로 영역이 생성됩니다.
예제와 같은 삼각형 영역을 구성하기 위한 좌표 중 우측 상단, 우측 하단 좌표 두 개는 고정으로 적용합니다. 그리고 아래 스타일 코드의 var(--safe-start) 에 해당하는 마우스 위치를 넘겨줘야 합니다.
.safeAreaElement {
clip-path: polygon(var(--safe-start), 100% 100%, 100% 0);
}
위 스타일을 적용하기 위한 마크업, 스타일은 다음과 같습니다.
<li className="hasSub">
Sort by
<div className="subMenu">
<ul>
<li>Default</li>
<li>Name</li>
<li>Type</li>
<li>Status</li>
</ul>
<span className="safeAreaElement" ref="{submenuSafeAreaRef}">
</span>
</div>
</li>
.hasSub {
position: relative;
}
.subMenu {
--safe-start: 0% 0%;
position: absolute;
left: calc(100% - 8px);
top: -2px;
z-index: 1;
width: 200px;
}
// safe triangle target area에 해당하는 스타일
// 2차 메뉴 좌측에 위치
.safeAreaElement {
position: absolute;
top: 0;
bottom: 0;
right: 100%;
width: calc(100% - 8px);
clip-path: polygon(var(--safe-start), 100% 100%, 100% 0);
}
여기에 마우스 x, y 좌표를 구하기 위한 자바스크립트 코드는 다음과 같습니다.
const updateCursorLocation = (event: MouseEvent<HTMLLIElement>) => {
/* 최상단 <li> 태그의 너비 */
const {
width: menuItemWidth,
height: menuItemHeight,
} = hasSubRef.current.getBoundingClientRect();
/* safe area의 너비, 끝 면 정보 */
const {
left: safeAreaLeftPos,
top: safeAreaTopPos,
width: safeAreaWidth,
height: safeAreaHeight,
} = submenuSafeAreaRef.current.getBoundingClientRect();
/* 현재 화면의 마우스 x, y값 */
const mouseX = event.clientX;
const mouseY = event.clientY;
/* (마우스 위치 - 요소의 끝 면 위치) = 요소 내 마우스 위치 */
const localX = mouseX - left;
const localY = mouseY - top;
/* 마우스가 1차 메뉴를 벗어나지 않는 조건 */
if (
localX > 0 && localX < menuItemWidth &&
localY > 0 && localY < menuItemHeight
) {
/* safe area 내 마우스 위치를 퍼센트로 환산 */
const percentageX = (localX / safeAreaWidth) * 100;
const percentageY = (localY / safeAreaHeight) * 100;
submenuRef.current.style.setProperty("--safe-start", `${percentageX}% ${percentageY}%`);
}
};
위 내용을 정리하면 다음과 같습니다.
- 마우스 위치(clientX, clientY)는 화면 기준으로 좌 → 우, 상 → 하 기준으로 제공하기 때문에 1차 메뉴의 left, top만큼 빼 메뉴 내 위치를 구한다.
- 마우스 위치를 퍼센트로 환산해 setProperty로 스타일 변수를 전달한다. 이때 마우스가 1차 위치를 벗어나지 않을 때만 업데이트한다.
- 전달한 스타일 변수를 clip-path에서 적용해 safe area를 생성한다.
💡 실제 적용 예시
위 예제를 참고해 두 가지 방식의 개선안을 준비했습니다.
1. 불필요한 코드 줄이기
'1차 메뉴와 2차 메뉴의 상단 위치가 같다면 아래 영역만 연산해도 되지 않을까?' 란 아이디어로 구현한 방식입니다. 이 방식을 적용하면 마우스의 Y 좌표에 대해 신경 쓸 필요가 없어집니다.
우선 코드는 다음과 같습니다.
const updateCursorLocation = (event: MouseEvent<HTMLButtonElement>) => {
const { left } = event.currentTarget?.getBoundingClientRect();
const mouseX = event.clientX;
const localX = mouseX - left;
const menuWidth = menuRef.current?.clientWidth || 0;
const percentageX = (localX / menuWidth) * 100;
menuRef.current?.style.setProperty('--percentage-x', `${percentageX}%`);
};
useEffect(() => {
if (!menuRef.current || !menuItemRef.current) return;
menuRef.current.style.setProperty('--menu-width', `${menuRef.current.clientWidth}px`);
menuRef.current.style.setProperty('--menu-item-height', `${menuItemRef.current.clientHeight}px`);
}, []);
$menu-width: var(--menu-width);
$menu-item-height: var(--menu-item-height);
$percentage-x: var(--percentage-x);
// 2차 메뉴 스타일
.subMenu {
position: absolute;
left: 100%;
top: 0;
// 빈 span 태그 대신 가상 선택자 활용
// Y좌표는 1차 메뉴의 하단 끝 위치로 고정
&::after {
content: '';
position: absolute;
left: calc(-1 * $menu-width);
width: $menu-width;
height: 100%;
clip-path: polygon($percentage-x $menu-item-height, 100% $menu-item-height, 100% 100%);
}
}
코드를 요약하자면,
- 최초 렌더 시 useEffect에서 1차 메뉴 너비, 높이를 스타일 변수로 전달
- 2차 메뉴의 가상 선택자로 safe area 생성, 위치와 너비는 1차 메뉴 너비 스타일 변수 기준으로 지정
- 마우스의 X 좌표값을 스타일 변수로 전달해 clip-path에 적용
개선점은 다음과 같습니다.
- 불필요한 빈 태그를 스타일로 대체해 제거
- clip-path의 Y좌표를 1차 메뉴 하단 위치로 고정해 Y좌표 연산 로직 제거
2. 마우스 연산 줄이기
마우스 위치에 따라 영역이 가변하면 분명 사용성은 좋아질 겁니다. 하지만 마우스가 움직일 때마다 이벤트 핸들러를 호출하거나 가변 하는 좌표로 인해 clip-path 스타일이 변경되면서 리페인트가 발생하는 단점이 있습니다. 따라서 위 이미지와 같이 최소한의 영역으로 고정해 적용해 보겠습니다.
@use 'sass:math';
$menu-width: var(--menu-width);
$menu-item-height: var(--menu-item-height);
// 1차 메뉴의 2/3에 해당하는 영역으로 너비 고정
$percentage-x: percentage(math.div(2,3));
.subMenu {
position: absolute;
left: 100%;
top: 0;
&::after {
content: '';
position: absolute;
left: calc(-1 * $menu-width);
width: $menu-width;
height: 100%;
clip-path: polygon($percentage-x $menu-item-height, 100% $menu-item-height, 100% 100%);
}
}
개선 방식은 다음과 같습니다.
- 연산 로직, 마우스 이벤트 제거
- 마우스 x 좌표는 1차 메뉴의 2/3에 해당하는 위치로 고정
위와 같이 최소한의 기능을 적용하며 성능에 이점을 얻도록 적용한 결과는 아래 이미지와 같습니다. 한눈에 봐도 연산이 줄어든 것을 확인하실 수 있습니다.
✨ 회고
처음 이 내용을 봤을 때 적잖이 놀랬던 기억이 납니다. 그동안 여러 작업을 하며 유저 입장에서 UI를 구현해왔다고 생각했는데, 이런 디테일한 부분까지 신경써야 진정으로 유저 사용성을 개선할 수 있다는 점을 배울 수 있었습니다. 버튼 하나, 동작 하나까지 섬세하게 바라보고 어떻게 하면 더 개선할 수 있는지에 대해 고민하는 자세를 가져야겠다는 생각이 드는 작업이었습니다.
소스코드와 테스트 페이지는 아래 링크에서 확인 가능합니다.
https://github.com/sangseophwang/safe-triangle-target-area-practice
https://safe-triangle-target-area-practice.vercel.app/
출처
'Programming > 3. Experience' 카테고리의 다른 글
useReducer + custom hook으로 state 관리하기 (0) | 2024.07.26 |
---|---|
emotion to scss module 도입기 (0) | 2024.07.25 |
서비스에 감정 불어넣기! @emotion 적용기 (0) | 2022.11.26 |
SCSS에서 Styled-Components로 변환하며 겪은 썰.txt (0) | 2022.05.16 |
[연습] Momentum 응용하기 (0) | 2021.06.23 |
댓글