컴포넌트

Tabs

관련 콘텐츠를 전환합니다.

모션

List 안에 Tabs.Indicator를 한 번 넣습니다. 탭을 선택하면 밑줄이 해당 탭의 위치와 너비로 300ms 동안 이동합니다. 키보드로 선택해도 같은 모션을 사용하며, 움직임 감소 설정에서는 즉시 전환합니다.

Tabs.Panels로 패널들을 묶으면 내용을 순서대로 전환합니다. 오른쪽 탭을 선택하면 기존 내용이 왼쪽으로 200ms 동안 퇴장한 다음 새 내용이 오른쪽에서 300ms 동안 들어옵니다. 왼쪽 탭은 반대로 움직입니다. --rbx-motion-delay-tabs를 생략하면 추가 간격은 0ms입니다. 이동 거리는 24px이며, 움직임 감소 설정에서는 대기 없이 바로 바뀝니다.

빠르게 다시 선택하면 마지막으로 선택한 패널이 표시됩니다. 대기 중인 내용은 클릭하거나 키보드로 접근할 수 없습니다.

설치

pnpm dlx ply-ui add tabs

공통 설치 설정을 먼저 적용합니다.

사용법

Tab과 Panel의 value를 일치시키고 Root에 초기 defaultValue를 지정합니다.

value/onValueChange로 현재 탭을 앱 상태나 URL과 연결할 수 있습니다.

구성

구성 요소역할
Root현재 탭 값을 관리합니다.
List탭 버튼을 묶습니다.
Tab고유한 value를 가진 탭 버튼입니다.
Panels퇴장과 입장을 같은 영역에서 재생하는 패널 컨테이너입니다.
Panel같은 value의 탭을 선택했을 때 표시하는 내용입니다.
Indicator선택한 탭의 위치와 너비를 따라 움직이는 밑줄입니다. List 안에 한 번 배치합니다.

선택과 포커스 분리

List.activateOnFocus={false}로 방향키는 포커스만 옮기고 Enter 또는 Space로 탭을 선택하게 합니다.

비활성 탭

사용할 수 없는 Tabdisabled를 지정합니다.

구현 코드

components/ui/tabs.tsx
tabs.tsx
"use client";

import type { ComponentProps } from "react";
import { Tabs as Primitive } from "@base-ui/react/tabs";
import { withClassName } from "../../lib/cx";
import "./tabs.css";

// 동작과 접근성은 Base UI가 담당합니다. 이 파일은 스타일 연결만 담당합니다.
function TabsRoot({
  className,
  ...props
}: ComponentProps<typeof Primitive.Root>) {
  return (
    <Primitive.Root
      {...props}
      className={withClassName("rbx-tabs", className)}
    />
  );
}

function TabsList({
  className,
  ...props
}: ComponentProps<typeof Primitive.List>) {
  return (
    <Primitive.List
      {...props}
      className={withClassName("rbx-tabs-list", className)}
    />
  );
}

function TabsTab({
  className,
  ...props
}: ComponentProps<typeof Primitive.Tab>) {
  return (
    <Primitive.Tab {...props} className={withClassName("rbx-tab", className)} />
  );
}

// 두 패널을 같은 칸에 두어 퇴장 중에도 내용 영역을 유지합니다.
function TabsPanels({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      {...props}
      className={["rbx-tab-panels", className].filter(Boolean).join(" ")}
    />
  );
}

function TabsPanel({
  className,
  ...props
}: ComponentProps<typeof Primitive.Panel>) {
  return (
    <Primitive.Panel
      {...props}
      className={withClassName("rbx-tab-panel", className)}
    />
  );
}

function TabsIndicator({
  className,
  ...props
}: ComponentProps<typeof Primitive.Indicator>) {
  return (
    <Primitive.Indicator
      {...props}
      className={withClassName("rbx-tab-indicator", className)}
    />
  );
}

// Root/Portal 등 스타일 없는 파트와 제네릭 API는 원본을 그대로 보존합니다.
export const Tabs = {
  ...Primitive,
  Root: TabsRoot,
  List: TabsList,
  Tab: TabsTab,
  Panels: TabsPanels,
  Panel: TabsPanel,
  Indicator: TabsIndicator,
};
components/ui/tabs.css
tabs.css
/* Tabs: 공통 구조는 theme.css, 컴포넌트 전용 조정은 아래에 작성합니다. */
.rbx-tabs-list {
  display: flex;
  gap: 24px;
  position: relative;
  border-bottom: 1px solid var(--rbx-color-stroke-default);
}
.rbx-tab {
  padding: 14px 0;
  border: 0;
  border-bottom: 2px solid transparent;
  background: transparent;
  color: var(--rbx-color-content-muted);
  font: 600 1rem var(--rbx-font-body);
  cursor: pointer;
  white-space: nowrap;
}
.rbx-tab[data-active] {
  color: var(--rbx-color-content-emphasis);
}
/* Indicator가 없는 기존 조합에서는 고정 밑줄을 유지합니다. */
.rbx-tabs-list:not(:has(.rbx-tab-indicator)) > .rbx-tab[data-active] {
  border-bottom-color: var(--rbx-color-content-emphasis);
}
.rbx-tab-panel {
  padding: 24px 0;
}
.rbx-tab-panels {
  display: grid;
  overflow: clip;
}
.rbx-tab-panels > .rbx-tab-panel {
  grid-area: 1 / 1;
  min-width: 0;
  --tab-enter-x: var(--rbx-motion-distance-tabs);
  --tab-enter-y: 0px;
}
.rbx-tab-panels > .rbx-tab-panel[data-activation-direction="left"] {
  --tab-enter-x: calc(-1 * var(--rbx-motion-distance-tabs));
}
.rbx-tab-panels > .rbx-tab-panel[data-activation-direction="down"] {
  --tab-enter-x: 0px;
  --tab-enter-y: var(--rbx-motion-distance-tabs);
}
.rbx-tab-panels > .rbx-tab-panel[data-activation-direction="up"] {
  --tab-enter-x: 0px;
  --tab-enter-y: calc(-1 * var(--rbx-motion-distance-tabs));
}
.rbx-tab-panels > .rbx-tab-panel:not([data-activation-direction="none"]) {
  opacity: 1;
  transform: translate(0, 0);
  visibility: visible;
  transition-property: opacity, transform, visibility;
  transition-duration:
    var(--rbx-motion-duration-panel), var(--rbx-motion-duration-panel), 0s;
  transition-timing-function: var(--rbx-motion-ease-exit);
  /* 추가 간격을 생략하면 0ms로 처리하고, 기존 내용의 퇴장만 기다립니다. */
  transition-delay: calc(
    var(--rbx-motion-duration-panel-exit) + var(--rbx-motion-delay-tabs, 0ms)
  );
}
@starting-style {
  .rbx-tab-panels > .rbx-tab-panel:not([data-activation-direction="none"]) {
    opacity: 0;
    transform: translate(var(--tab-enter-x), var(--tab-enter-y));
    visibility: hidden;
  }
}
.rbx-tab-panels
  > .rbx-tab-panel[data-starting-style]:not(
    [data-activation-direction="none"]
  ) {
  opacity: 0;
  transform: translate(var(--tab-enter-x), var(--tab-enter-y));
  visibility: hidden;
}
.rbx-tab-panels > .rbx-tab-panel[data-ending-style] {
  opacity: 0;
  transform: translate(
    calc(-1 * var(--tab-enter-x)),
    calc(-1 * var(--tab-enter-y))
  );
  transition-duration: var(--rbx-motion-duration-panel-exit);
  transition-timing-function: var(--rbx-motion-ease-exit);
  transition-delay: 0s;
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
  .rbx-tab-panels > .rbx-tab-panel {
    transition: none;
    transform: none;
  }
}
.rbx-tab-indicator {
  position: absolute;
  bottom: 0;
  height: 2px;
  background: var(--rbx-color-content-emphasis);
  left: 0;
  transform: translateX(var(--active-tab-left));
  width: var(--active-tab-width);
  pointer-events: none;
}

탭 콘텐츠는 퇴장과 등장 모두 standard-in 곡선을 사용합니다. 끝으로 갈수록 빨라지는 움직임으로 통일합니다.

목차