우리 회사는 틈새 시장에서 가치를 창출하는 데 집중하고 나머지는 복사합니다. 제품에 탭 구성 요소가 필요했을 때 Vercel 이 가장 먼저 떠올랐습니다. 탭 구성 요소가 정확히 복사되지는 않았지만 이 글에서는 코드를 정확히 복사하려고 합니다.
[Vercel 대시보드에서 볼 수 있는 탭 구성 요소]
이 글에서는 CSS, React Transition Group, React-Spring, Framer Motion을 사용하여 이 컴포넌트를 만드는 방법을 분석해보겠습니다.
우리가 추구하는 바는 다음과 같습니다.
기본적인 탭 구성 요소 형태는 다음과 같습니다. hoverStyles와 selectStyles 적용을 위해 div 요소를 각각 만들어줍니다.
<nav
ref={navRef}
className="flex flex-shrink-0 justify-center items-center relative z-0 py-2"
onPointerLeave={onLeaveTabs}
>
{tabs.map((item, i) => {
return (
<button
key={i}
ref={(el) => (buttonRefs[i] = el)}
className={cn(
'text-sm flex items-center h-8 px-4 text-slate-500 relative z-20 cursor-pointer select-none transition-colors',
(hoveredTabIndex === i || selectedTabIndex === i) &&
'text-slate-700',
)}
onPointerEnter={(e) => onEnterTab(e, i)}
onFocus={(e) => onEnterTab(e, i)}
onClick={() => onSelectTab(i)}
>
{item.label}
</button>
);
})}
<div
className="absolute z-10 top-0 left-0 rounded-md bg-gray-200 transition-[width]"
style={hoverStyles}
/>
<div
className={'absolute z-10 bottom-0 left-0 h-0.5 bg-slate-500'}
style={selectStyles}
/>
</nav>
그리고 호버 시 상태를 변화 시키고, hoverStyles를 변경 시킵니다. transform으로 이동 시키고 transition으로 애니메이션 효과를 적용했습니다.
const [isInitialHoveredElement, setIsInitialHoveredElement] = useState(true);
const onEnterTab = (
e:
| React.PointerEvent<HTMLButtonElement>
| React.FocusEvent<HTMLButtonElement>,
i: number,
) => {
if (!e.target || !(e.target instanceof HTMLButtonElement)) return;
setHoveredTabIndex((prev) => {
if (prev !== null && prev !== i) {
setIsInitialHoveredElement(false);
}
return i;
});
setHoveredRect(e.target.getBoundingClientRect());
};
const onLeaveTabs = () => {
setIsInitialHoveredElement(true);
setHoveredTabIndex(null);
};
let hoverStyles: CSSProperties = { opacity: 0 };
if (navRect && hoveredRect) {
hoverStyles.transform = `translate3d(${hoveredRect.left - navRect.left}px,${
hoveredRect.top - navRect.top
}px,0px)`;
hoverStyles.width = hoveredRect.width;
hoverStyles.height = hoveredRect.height;
hoverStyles.opacity = hoveredTabIndex != null ? 1 : 0;
// 주의. hover 상태에서 다른 요소로 이동 시는 이동 효과 부여. 그게 아니라 나갔다가 호버되는 경우는 opacity만 부여
hoverStyles.transition = isInitialHoveredElement
? `opacity 150ms`
: `transform 150ms 0ms, opacity 150ms 0ms, width 150ms`;
}
선택 시 밑줄 효과 애니메이션도 마찬가지 입니다. 적절히 상태 변경 후 selectStyles를 변경 시킵니다.
const selectedRect = buttonRefs[selectedTabIndex]?.getBoundingClientRect();
const onSelectTab = (i: number) => {
setSelectedTab(i);
};
let selectStyles: CSSProperties = { opacity: 0 };
if (navRect && selectedRect) {
selectStyles.width = selectedRect.width * 0.8;
selectStyles.transform = `translateX(calc(${
selectedRect.left - navRect.left
}px + 10%))`;
selectStyles.opacity = 1;
selectStyles.transition = isInitialRender.current
? `opacity 150ms 150ms`
: `transform 150ms 0ms, opacity 150ms 150ms, width 150ms`;
isInitialRender.current = false;
}
CSS 애니메이션은 가장 첫번째 원칙으로 사물을 이해하는 데 매우 유용합니다
하지만 몇 가지 문제점도 있습니다.
호버 애니메이션은 pointerEnter에서 opacity를 애니메이션하고, 포인터가 탭을 이동할 때 opacity, width, transform를 애니메이션 합니다.
이렇게 하려면 transition 속성을 토글하기 위해 상태를 유지해야 합니다.
// state related to `hovered` animation
const [isInitialHoveredElement, setIsInitialHoveredElement] = useState(true)
const onLeaveTabs = () => {
// reset `isInitialHoveredElement` when the pointer leaves the tabs
setIsInitialHoveredElement(true)
setHoveredTabIndex(null)
}
const onEnterTab = (/* ... */) => {
// ...
setHoveredTabIndex(prev => {
// set `isInitialHoveredElement` if the value is being assigned
// from == null (pointer has entered tabs component)
if (prev != null && prev !== i) {
setIsInitialHoveredElement(false)
}
return i
})
// ...
}
select animation은 다른 수명 주기를 갖습니다. DOM에 대한 참조가 초기화되면 opacity 애니메이션이 실행되고, 선택이 변경되면 opacity, width, transform 를 애니메이션화합니다.
호버 애니메이션과 마찬가지로 transition 속성을 전환하려면 renderCount 상태가 필요합니다.
// state related to `selection` animation
const isInitialRender = useRef(true)
// since the ref isn't defined on the first render I want the
// selection indicator to animate in with just the opacity
selectStyles.transition = isInitialRender.current
? `opacity 150ms 150ms`
: `transform 150ms 0ms, opacity 150ms 150ms`
호버된 요소의 바운딩 박스에 따라 호버 hovered 애니메이션의 크기를 변경하고 있습니다. hovered 애니메이션이 사라지면서 크기가 변경되지 않는 것이 중요합니다.
따라서 size와 visibility은 state에서 별도로 저장되어야 합니다.
// storing visibility as the presence of a `hoveredTabIndex`
const [hoveredTabIndex, setHoveredTabIndex] = useState<number | null>(null)
// storing size as a DOMRect
const [hoveredRect, setHoveredRect] = useState<DOMRect | null>(null)
종료 상태를 크기별로 추적하는 것은 하나의 요소에 대해서는 괜찮지만, 이렇게 하면 요소 배열을 추적하거나 요소 간에 전환할 수 있는 구성 요소들이 증가할 수 있습니다.
JSX에서는 statements(only expressions) 허용하지 않기 때문입니다. 상태를 설정하고 스타일을 동시에 변경하기 위해 let과 if를 사용하고 있습니다.
let selectStyles: CSSProperties = { opacity: 0 }
if (navRect && selectedRect) {
selectStyles.width = selectedRect.width * 0.8
selectStyles.transform = `translateX(calc(${
selectedRect.left - navRect.left
}px + 10%))`
selectStyles.opacity = 1
selectStyles.transition = isInitialRender.current
? `opacity 150ms 150ms`
: `transform 150ms 0ms, opacity 150ms 150ms, width 150ms`
// setting `isInitialRender` state so that on
// following renders the transition will be different
isInitialRender.current = false
}
React-Spring 부터 모양을 움직이고 애니메이션을 내외부로 움직이는 애니메이션이 있습니다. 여기서는 그렇게 하려고 하지 않았는데, CSS에 레이아웃 이동을 일으키지 않고 깔끔하게 하는 추상화가 없기 때문입니다.
이 지점을 넘어서, 저는 라이브러리 API를 비교할 것입니다. 그것들은 모두 OSS이고, 제 목표는 불평하는 것이 아닙니다. 그러나 저는 광범위한 사용으로 인해 제가 겪은 고통스러운 점들을 공유하고 싶습니다.