[번역/실습] Vercel tabs component

기운찬곰·2025년 3월 24일
0

프론트개발이모저모

목록 보기
21/21

출처: https://www.joshuawootonn.com/vercel-tabs-component

우리 회사는 틈새 시장에서 가치를 창출하는 데 집중하고 나머지는 복사합니다. 제품에 탭 구성 요소가 필요했을 때 Vercel 이 가장 먼저 떠올랐습니다. 탭 구성 요소가 정확히 복사되지는 않았지만 이 글에서는 코드를 정확히 복사하려고 합니다.

[Vercel 대시보드에서 볼 수 있는 탭 구성 요소]

이 글에서는 CSS, React Transition Group, React-Spring, Framer Motion을 사용하여 이 컴포넌트를 만드는 방법을 분석해보겠습니다.

우리가 추구하는 바는 다음과 같습니다.

  1. 탭 버튼 호버 시 호버 박스가 애니메이션 형태로 좌우로 슬라이드 되면서 움직일 것
  2. 탭 버튼 선택 시 밑줄 선도 마찬가지로 애니메이션 형태로 좌우로 슬라이드 되면서 움직일 것.

CSS

기본적인 탭 구성 요소 형태는 다음과 같습니다. 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이고, 제 목표는 불평하는 것이 아닙니다. 그러나 저는 광범위한 사용으로 인해 제가 겪은 고통스러운 점들을 공유하고 싶습니다.

React Transition Group

React-Spring

Framer Motion

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

0개의 댓글

관련 채용 정보