(번역/실습) Vercel tabs component

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

출처: 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 [hoveredTabIndex, setHoveredTabIndex] = useState<number | null>(null);
const [hoveredRect, setHoveredRect] = useState<DOMRect | null>(null);

const [isInitialHoveredElement, setIsInitialHoveredElement] = useState(true); // 🤔

const onEnterTab = (
  e: PointerEvent<HTMLButtonElement> | 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;
  hoverStyles.transition = isInitialHoveredElement  // 🤔
    ? `opacity 150ms`
    : `transform 150ms 0ms, opacity 150ms 0ms, width 150ms`;
}

선택 시 밑줄 효과 애니메이션도 마찬가지 입니다. 적절히 상태 변경 후 selectStyles를 변경 시킵니다.

const selectedRect = buttonRefs[selectedTabIndex]?.getBoundingClientRect();

const isInitialRender = useRef(true); // 🤔

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 애니메이션은 가장 첫번째 원칙으로 사물을 이해하는 데 매우 유용합니다

하지만 몇 가지 문제점도 있습니다.

  • 첫 번째 렌더링 추적
  • 종료를 애니메이션화하기 위해 수동으로 상태를 유지합니다.

그리고 또한,

  • 스타일을 구성하는 간결한 방법은 없습니다
  • 콘텐츠 간 애니메이션을 쉽게 적용할 수 있는 방법이 없습니다.

첫 번째 렌더링 추적

hover 애니메이션은 초기에 탭 컴포넌트에 Enter/Exit (들어오거나 나가거나) 시 pointerEnter에서 opacity를 애니메이션하고, 포인터가 탭을 이동할 때 opacity, width, transform를 함께 애니메이션 합니다.

이렇게 하려면 transition 속성을 토글하기 위해 별도 상태(isInitialHoveredElement)를 유지해야 합니다. 아하.

// 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 애니메이션은 다른 수명 주기를 갖습니다. DOM에 대한 참조( ref )가 초기화되면 opacity 애니메이션이 실행되고, 선택이 변경되면 opacity, width, transform 를 애니메이션화합니다.

호버 애니메이션과 마찬가지로 transition 속성을 전환하려면 isInitialRender 상태가 필요합니다.

// 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`

Exit 를 애니메이션 하기 위해 수동으로 상태를 유지

호버된 요소의 바운딩 박스에 따라 호버 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 Transition Group은 최초의 대형 리액트 애니메이션 라이브러리였습니다. 이 라이브러리는 "첫 번째 렌더링 추적" 및 "Exit 상태 수동 유지" 문제를 해결합니다. 그리고 이 라이브러리는 작은 번들 사이즈를 가지고 있습니다.

The delta between RTG and CSS

React Transition Group은 Transition 컴포넌트를 제공하는데, render prop 패턴을 사용합니다.

const hoverStyles: CSSProperties =
  navRect && hoveredRect
    ? {
        transform: `translate3d(${hoveredRect.left - navRect.left}px,${
          hoveredRect.top - navRect.top
        }px,0px)`,
        width: hoveredRect.width,
        height: hoveredRect.height,
      }
    : {};

const selectStyles: CSSProperties =
  navRect && selectedRect
    ? {
        width: selectedRect.width * 0.8,
        transform: `translateX(calc(${
          selectedRect.left - navRect.left
        }px + 10%))`,
      }
    : {};

...

<Transition in={hoveredTabIndex != null} timeout={duration} nodeRef={hoverNodeRef}>
    {state => (
        <div
            className="absolute z-10 top-0 left-0 rounded-md bg-gray-200 transition-[width]"
            style={{
                ...hoverStyles,
                ...transitionStyles[state],
            }}
        />
    )}
</Transition>

Transition 컴포넌트를 사용하면 간단한 선언적 API를 통해 시간 경과에 따른 한 컴포넌트 상태에서 다른 컴포넌트 상태로의 전환을 설정할 수 있습니다. 기본적으로 Transition 컴포넌트는 렌더링하는 컴포넌트의 동작을 변경하지 않고, 컴포넌트의 "enter" 및 "exit" 상태만 추적합니다. 이러한 상태에 의미와 효과를 부여하는 것은 개발자의 몫입니다.

전환은 4가지 주요 상태로 나뉩니다.

const transitionStyles = {
    entering: {
        opacity: 1,
        transition: `transform 0ms, opacity 150ms, width 0ms`,
    },
    entered: {
        opacity: 1,
        transition: `transform 150ms 0ms, opacity 150ms 0ms, width 150ms`,
    },
    exiting: {
        opacity: 0,
        transition: `transform 0ms, opacity 150ms, width 0ms`,
    },
    exited: { opacity: 0 },
}

전환 상태는 in prop을 통해 전환됩니다. in이 true가 되면, 컴포넌트가 "entering" 상태로 전환이 되며, duration 동안 유지 됩니다. 이후 "entered" 상태로 전환됩니다. 상태가 "exit"로 이동하는 경우도 마찬가지입니다. in prop이 false로 바뀌면 단계적으로 'exiting', 'exited'로 전환됩니다.

❗️ 사용시 주의사항으로 nodeRef를 설정해야 에러가 안나네요. React 최신 버전에서 findDOMNode 사용이 권장되지 않기 때문에, 이런 사용을 피하기 위해서는 nodeRef 설정이 필요하다고 합니다.

실제 테스트 해보니 전환 이벤트가 알아서 잘 발생하고 있습니다. 탭 간 이동 시에는 'entered' 상태가 유지됩니다.

결과적으로 React Transition Group 내부 로직 덕분에 isInitialHoveredElementisInitialRender 상태를 관리할 필요가 없어서 코드가 간결해졌습니다.

스타일은 아직도 그다지 간결하지 않다

장점: 이제 렌더 호출에서 참조를 설정할 필요가 없으므로 스타일을 구성하는 데 사용할 수 있는 옵션이 더 많습니다.

단점: TypeScript를 사용하여 애니메이션 스타일을 정의할 때, 모든 상태에 대한 스타일을 명시적으로 지정해야 하는데, 이는 코드가 명확해지는 장점이 있지만, 특히 잘 사용하지 않는 상태에 대해서도 스타일을 지정해야 하므로 코드가 복잡해질 수 있습니다. 스타일 객체가 복잡해지고 관리하기 어려워질 수 있습니다.

const inTransition = { ... }
const outTransition ={ ... }
 
const transitionStyles = {
  entering: { ...inTransition },
  entered: { ...inTransition, onOffProperty: "whyAreWeStillHere" },
  exiting: { ...outTransition },
  exited: { ...outTransition, onOffProperty: "justToSuffer" }
  unmounted: { ... },
};

요점은 -> 이 객체는 복잡한 애니메이션을 유지하는 게 재미없다는 거예요.

React-Spring

React-Spring에 대해 이야기해 보겠습니다. Next.js가 후원하는 React-Spring 은 Vercel 에서 사용하는 것과 거의 같습니다. React-Spring는 더 큰 빌드 사이즈를 가지고 있으므로 React-Transition-Group보다 기능이 훨씬 더 많을 것으로 예상할 수 있습니다.

The delta between Spring and CSS

React Transition Group과 마찬가지로 React-Spring에는 요소를 in과 out으로 전환하는 솔루션이 있습니다 .

const stylesChangingOnUpdate =
    hoveredRect && navRect
        ? {
              transform: `translate3d(${hoveredRect.left - navRect.left}px,${
                  hoveredRect.top - navRect.top
              }px,0px)`,
              width: hoveredRect.width,
              height: hoveredRect.height,
          }
        : {}
 
const bgTransition = useTransition(hoveredTabIndex != null, {
    from: () => ({  // 초기 상태 (시작 스타일)
        ...stylesChangingOnUpdate,
        opacity: 0,
    }),
    enter: {  // 요소가 나타날 때의 상태
        ...stylesChangingOnUpdate,
        opacity: 1,
    },
    update: stylesChangingOnUpdate,  // 요소가 업데이트될 때의 상태
    leave: { opacity: 0 },  // 요소가 사라질 때의 상태
    config: {
        duration: 150,
        easing: easings.easeOutCubic,
    },
})

...

{bgTransition((styles) => (
  <animated.div
    className="absolute z-10 rounded-md top-0 left-0 bg-gray-200"
    style={styles}
  />
))}

첫 번째 매개변수에 따라 콘텐츠를 토글할 수 있는 hook API와 render prop API가 있습니다. 이는 RTG의 prop과 유사합니다.

React-Spring에서는 선택한 밑줄을 더 이상 transition로 취급할 필요가 없습니다. 참조가 초기화되면 from prop을 사용하지 않기 때문에 useSpring은 width를 설정하고 animate 하지 않습니다.

const underlineStyles = useSpring({
    to:
        selectedRect && navRect  // 이후 업데이트 시 이전 상태 값을 시작점으로 사용해 애니메이션
            ? {
                  width: selectedRect.width * 0.8,
                  transform: `translateX(calc(${
                      selectedRect.left - navRect.left
                  }px + 10%))`,
                  opacity: 1,
              }
            : { opacity: 0 },  // from이 생략되면 현재 값을 초기값으로 사용
    config: {
        duration: 150,
        easing: easings.easeOutCubic,
    },
})

...

<animated.div
  className="absolute bottom-0 left-0 z-10 h-0.5 bg-slate-500"
  style={underlineStyles}
/>

React Spring을 사용하면 도형들이 화면에 나타나고 사라지는 애니메이션을 깔끔하게 구현할 수 있습니다.

const Content = ({
  selectedTabIndex,
  direction,
  tabs,
  className,
}: {
  selectedTabIndex: number;
  direction: number;
  tabs: Tab[];

  className?: string;
}) => {
  const transitions = useTransition(selectedTabIndex, {
    exitBeforeEnter: false, // false: 이전 요소가 사라지기 전에 새 요소가 나타날 수 있음
    keys: null, //  // 각 전환에 대한 고유 키 (null이면 자동 생성)
    from: {  // 시작 상태
      opacity: 0,
      transform: `translate3d(${
        direction > 0 ? '100' : '-100'
      }px,0,0) scale(0.8)`,
    },
    enter: { opacity: 1, transform: 'translate3d(0px,0,0) scale(1)' },  // 진입 상태
    leave: {  // 퇴장 상태
      opacity: 0,
      transform: `translate3d(${
        direction > 0 ? '-100' : '100'
      }px,0,0) scale(0.8)`,
      position: 'absolute',
    },
    config: {
      duration: 250,
      easing: easings.easeOutCubic,
    },
  });

  return transitions((styles, item) => (
    <animated.div key={selectedTabIndex} style={styles} className={className}>
      {tabs[item].children}
    </animated.div>
  ));
};

탭이 변경되면 selectedTabIndex가 업데이트 됩니다. direction에 따라 새로운 탭이 오른쪽 또는 왼쪽에서 나타납니다. 기존 탭은 반대 방향으로 사라집니다. 이 컴포넌트는 탭 전환 시 부드러운 슬라이드 효과와 크기 변화를 동시에 적용하여 자연스러운 애니메이션 효과를 구현할 수 있습니다. (오...)

Reacty Hook API

애니메이션에 hook 패턴을 사용하면 전환 코드와 마크업을 별도로 관리할 수 있습니다. 모든 구성 요소가 100줄 미만인 이상적인 환경에서는 큰 문제가 되지 않겠지만, 실제로는 디버깅에 많은 어려움을 겪습니다.

아직 렌더 속성 API가 있지만, 커뮤니티의 대부분은 hook를 중심으로 움직입니다.

API 혼란

useSpring vs useTransition vs useChain vs useTrail. 무엇을 언제 사용해야 하는지 아는 것이 React-Spring의 학습 곡선을 다른 어떤 것보다 높게 만듭니다. 몇 년 전 React-Spring을 배우던 시절, 각 후크가 다루는 추상화를 이해하지 못한 채 여러 후크 사이를 왔다 갔다 해야 했던 좌절감이 기억납니다.

Framer Motion

Framer Motion은 마지막 라이브러리이자 제가 가장 좋아하는 라이브러리입니다(스포일러). 더 자세히 설명하겠지만, 제 생각에는 Framer Motion이 가장 사용하기 쉽습니다. 아쉽게도 가장 큰 단점은 번들 크기가 너무 크다는 것입니다. React Spring 보다 3배 더 큽니다.

The delta between Framer Motion and React-Spring

Framer Motion은 DOM에서 벗어나는 요소를 추적하기 위해 AnimatePresence라는 구성 요소와 exit이라는 prop를 사용합니다.

React와 유사하게, Framer Motion은 주요 속성을 사용하여 렌더링 전반에서 요소를 구별합니다.

<AnimatePresence>
    {hoveredRect && navRect && (
        <motion.div
            key={'hover'}
            className="absolute z-10 top-0 left-0 rounded-md bg-gray-200"
            initial={{
                x: hoveredRect.left - navRect.left,
                y: hoveredRect.top - navRect.top,
                width: hoveredRect.width,
                height: hoveredRect.height,
                opacity: 0,
            }}
            animate={{
                x: hoveredRect.left - navRect.left,
                y: hoveredRect.top - navRect.top,
                width: hoveredRect.width,
                height: hoveredRect.height,
                opacity: 1,
            }}
            exit={{
                x: hoveredRect.left - navRect.left,
                y: hoveredRect.top - navRect.top,
                width: hoveredRect.width,
                height: hoveredRect.height,
                opacity: 0,
            }}
            transition={transition}
        />
    )}
</AnimatePresence>

React-Spring은 첫 번째 렌더링 문제를 from이 undefined가 되는걸로 추상화한 반면, Framer Motion은 initial={false}으로 추상화합니다.

selectedRect && navRect && (
    <motion.div
        className={'absolute z-10 bottom-0 left-0 h-[2px] bg-slate-500'}
        initial={false}
        animate={{
            width: selectedRect.width * 0.8,
            x: `calc(${selectedRect.left - navRect.left}px + 10%)`,
            opacity: 1,
        }}
        transition={transition}
    />
)

Reacty (prop API)

Framer Motion에서 제가 가장 좋아하는 마지막 포인트는 전환 효과를 작성할 때 Prop API를 사용한다는 점입니다. Tailwind와 Framer Motion을 함께 사용하면 애니메이션, 스타일, 마크업을 모두 한 곳에서 작성할 수 있습니다.

결론

사실, 여기서는 우승자가 꼭 필요하다고 생각하지는 않지만, 흥미로운 패턴이 몇 가지 있습니다.

  • 자체적으로 구축된 API는 이해하기가 더 쉽습니다.
  • DX 및 번들 크기에 대한 라인은 종종 상관 관계가 있습니다.

저는 개인적으로 번들의 영향에 대해 생각할 필요 없이 스타일과 애니메이션이 함께 배치되는 미래를 바라고 있습니다.

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

0개의 댓글