컴포넌트

Sidebar

페이지 탐색 메뉴를 표시합니다.

모션

열림과 닫힘 모두 500ms 동안 가장자리에서 이동합니다. 문서 사이트의 탐색 사이드바에도 같은 시간을 적용합니다. 움직임 감소 설정에서는 전환을 생략합니다. 공통 모션 규칙을 따릅니다.

설치

pnpm dlx ply-ui add sidebar badge button icon

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

사용법

고정 패널은 Root, 반응형은 Provider/Panel/Trigger를 사용합니다.

페이지 이동은 Link, 화면 안의 작업은 Action을 사용합니다. 활성 상태는 active로 지정합니다.

구성

구성 요소역할
Root항상 표시하는 고정 사이드바입니다.
Provider반응형 패널의 열림 상태를 관리합니다. open은 모바일 패널 상태입니다.
Panel1140px 이하에서 모달 패널로, 넓은 화면에서는 고정 영역으로 표시합니다.
Trigger모바일 패널을 엽니다. Provider 안에 배치합니다.
Header / Content / Footer상단, 스크롤되는 본문, 하단 영역입니다.
Group / GroupLabel탐색 항목을 묶고 이름을 표시합니다.
Menu / Item탐색 목록과 각 행입니다.
Link주소로 이동하는 NavigationLink입니다.
Action화면 안의 작업을 실행하는 NavigationItem입니다.

반응형 패널

Provider, Panel, Trigger를 함께 사용합니다. 새 창에서 너비를 바꾸면 고정 영역과 모바일 패널 전환을 확인할 수 있습니다.

구현 코드

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

import {
  createContext,
  useContext,
  useEffect,
  useState,
  useRef,
  useSyncExternalStore,
  type ComponentProps,
  type ReactNode,
} from "react";
import { Dialog } from "@base-ui/react/dialog";
import { cx, withClassName } from "../../lib/cx";
import { IconButton } from "./icon-button";
import { ScrollArea } from "./scroll-area";
import { NavigationItem, NavigationLink } from "./navigation-item";
import "./sidebar.css";

// CSS의 breakpoint와 맞춥니다. SSR에서는 desktop → hydration 후 실제 viewport 적용.
const mobileQuery = "(max-width: 1140px)";
function subscribeViewport(notify: () => void) {
  const media = window.matchMedia(mobileQuery);
  media.addEventListener("change", notify);
  return () => media.removeEventListener("change", notify);
}
const getMobile = () => window.matchMedia(mobileQuery).matches;
const getServerMobile = () => false;
type SidebarContextValue = {
  mobile: boolean;
  open: boolean;
  setOpen: (open: boolean) => void;
  handle: ReturnType<typeof Dialog.createHandle>;
  actionsRef: React.RefObject<Dialog.Root.Actions | null>;
};
const SidebarContext = createContext<SidebarContextValue | null>(null);
function useSidebar() {
  const context = useContext(SidebarContext);
  if (!context)
    throw new Error(
      "Sidebar.Panel/Trigger는 Sidebar.Provider 안에 배치하세요.",
    );
  return context;
}

type ProviderProps = {
  children: ReactNode;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
};
/** open은 모바일 패널의 상태입니다. desktop에서는 항상 표시합니다. */
function SidebarProvider({
  children,
  open,
  defaultOpen = false,
  onOpenChange,
}: ProviderProps) {
  const mobile = useSyncExternalStore(
    subscribeViewport,
    getMobile,
    getServerMobile,
  );
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
  const dialogActions = useRef<Dialog.Root.Actions>(null);
  const [handle] = useState(() => Dialog.createHandle());
  const isOpen = open ?? internalOpen;
  const setOpen = (next: boolean) => {
    setInternalOpen(next);
    onOpenChange?.(next);
  };
  // 넓은 화면으로 전환하면 focus trap / scroll lock을 해제하고 다음 열림도 초기화합니다.
  useEffect(() => {
    if (!mobile) {
      // Panel이 desktop DOM으로 바뀌면 transitionend가 발생하지 않습니다.
      // Base UI의 공식 action으로 종료 상태도 정리해 재등장하는 빈 모달을 막습니다.
      dialogActions.current?.unmount();
      if (isOpen) {
        setInternalOpen(false);
        onOpenChange?.(false);
      }
    }
  }, [mobile, isOpen, onOpenChange]);
  return (
    <SidebarContext.Provider
      value={{
        mobile,
        open: mobile && isOpen,
        setOpen,
        handle,
        actionsRef: dialogActions,
      }}
    >
      {/* Dialog.Root는 Panel에만 둡니다. 앱 전체를 감싸면 다른 모달의 Backdrop이 생략됩니다. */}
      {children}
    </SidebarContext.Provider>
  );
}
function SidebarRoot({ className, ...props }: ComponentProps<"aside">) {
  return <aside {...props} className={cx("rbx-sidebar", className)} />;
}
/** 단일 children 트리만 렌더합니다. 검색 input/id가 desktop/mobile에 중복되지 않습니다. */
function SidebarPanel({
  title,
  closeLabel = "Close navigation",
  children,
  className,
  ...props
}: ComponentProps<"aside"> & { title: string; closeLabel?: string }) {
  const { mobile, open, setOpen, handle, actionsRef } = useSidebar();
  return (
    <Dialog.Root
      open={open}
      onOpenChange={setOpen}
      handle={handle}
      actionsRef={actionsRef}
    >
      {!mobile ? (
        <SidebarRoot aria-label={title} {...props} className={className}>
          {children}
        </SidebarRoot>
      ) : (
        <Dialog.Portal>
          <Dialog.Backdrop className="rbx-sidebar-backdrop" />
          <Dialog.Popup
            className="rbx-sidebar-popup"
            aria-describedby={undefined}
          >
            <div className="rbx-sidebar-mobile-heading">
              <Dialog.Title className="rbx-sidebar-mobile-title">
                {title}
              </Dialog.Title>
              <Dialog.Close
                render={
                  <IconButton
                    icon="icon-regular-x"
                    size="sm"
                    variant="utility"
                    aria-label={closeLabel}
                  />
                }
              />
            </div>
            <SidebarRoot {...props} className={className}>
              {children}
            </SidebarRoot>
          </Dialog.Popup>
        </Dialog.Portal>
      )}
    </Dialog.Root>
  );
}
function SidebarTrigger({
  className,
  ...props
}: ComponentProps<typeof Dialog.Trigger>) {
  const { handle } = useSidebar();
  return (
    <Dialog.Trigger
      handle={handle}
      {...props}
      className={withClassName("rbx-sidebar-trigger", className)}
    />
  );
}
function SidebarContent({
  children,
  className,
  ...props
}: ComponentProps<typeof ScrollArea.Root>) {
  return (
    <ScrollArea.Root
      {...props}
      className={withClassName("rbx-sidebar-scroll", className)}
    >
      <ScrollArea.Viewport className="rbx-sidebar-viewport">
        {children}
      </ScrollArea.Viewport>
      <ScrollArea.Scrollbar>
        <ScrollArea.Thumb />
      </ScrollArea.Scrollbar>
    </ScrollArea.Root>
  );
}
function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
  return <div {...props} className={cx("rbx-sidebar-header", className)} />;
}
function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
  return <div {...props} className={cx("rbx-sidebar-footer", className)} />;
}
function SidebarGroup({ className, ...props }: ComponentProps<"div">) {
  return <div {...props} className={cx("rbx-sidebar-group", className)} />;
}
function SidebarGroupLabel({ className, ...props }: ComponentProps<"h2">) {
  return <h2 {...props} className={cx("rbx-sidebar-group-label", className)} />;
}
function SidebarMenu({ className, ...props }: ComponentProps<"ul">) {
  return <ul {...props} className={cx("rbx-sidebar-menu", className)} />;
}
function SidebarItem({ className, ...props }: ComponentProps<"li">) {
  return <li {...props} className={cx("rbx-sidebar-item", className)} />;
}
export const Sidebar = {
  Provider: SidebarProvider,
  Panel: SidebarPanel,
  Trigger: SidebarTrigger,
  Root: SidebarRoot,
  Header: SidebarHeader,
  Content: SidebarContent,
  Footer: SidebarFooter,
  Group: SidebarGroup,
  GroupLabel: SidebarGroupLabel,
  Menu: SidebarMenu,
  Item: SidebarItem,
  Action: NavigationItem,
  Link: NavigationLink,
};
components/ui/sidebar.css
sidebar.css
/* Navigation.css: 288px content + 1px separator. Height/position belong to the host layout. */
.rbx-sidebar {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  width: var(--rbx-sidebar-width, 289px);
  max-width: 100%;
  min-height: 0;
  background: var(--rbx-color-surface-0);
  color: var(--rbx-color-content-emphasis);
  border-inline-end: 1px solid var(--rbx-color-stroke-default);
}
.rbx-sidebar-scroll {
  flex: 1;
  min-height: 0;
}
.rbx-sidebar-viewport {
  box-sizing: border-box;
  padding: 12px 16px;
}
.rbx-sidebar-header,
.rbx-sidebar-footer {
  padding: 12px 16px;
  flex-shrink: 0;
}
.rbx-sidebar-footer {
  color: var(--rbx-color-content-muted);
  font-size: 12px;
}
.rbx-sidebar-group + .rbx-sidebar-group {
  margin-top: 24px;
}
.rbx-sidebar-group-label {
  margin: 0 0 12px;
  padding-inline: 12px;
  color: var(--rbx-color-content-muted);
  font: 600 12px/1.4 var(--rbx-font-body);
}
.rbx-sidebar-menu {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.rbx-sidebar-item {
  min-width: 0;
  padding: 0;
  margin: 0;
}
/* 앱에서 정한 500ms 모션을 사용합니다. 좌우 방향과 포커스 동작은 유지합니다. */
.rbx-sidebar-backdrop {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: rgb(0 0 0 / 0.5);
  transition: opacity var(--rbx-motion-duration-sidebar)
    var(--rbx-motion-ease-enter);
}
.rbx-sidebar-popup {
  box-sizing: border-box;
  position: fixed;
  inset-block: 0;
  inset-inline-start: 0;
  z-index: 51;
  display: flex;
  flex-direction: column;
  width: var(--rbx-sidebar-width, 289px);
  max-width: calc(100vw - 40px);
  background: var(--rbx-color-surface-0);
  color: var(--rbx-color-content-emphasis);
  transition: transform var(--rbx-motion-duration-sidebar)
    var(--rbx-motion-ease-enter);
  outline: none;
  padding-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
}
.rbx-sidebar-popup > .rbx-sidebar {
  flex: 1;
  width: 100%;
}
.rbx-sidebar-popup[data-starting-style],
.rbx-sidebar-popup[data-ending-style] {
  transform: translateX(-100%);
}
.rbx-sidebar-popup:dir(rtl)[data-starting-style],
.rbx-sidebar-popup:dir(rtl)[data-ending-style] {
  transform: translateX(100%);
}
.rbx-sidebar-backdrop[data-starting-style],
.rbx-sidebar-backdrop[data-ending-style] {
  opacity: 0;
}
.rbx-sidebar-mobile-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 16px;
}
.rbx-sidebar-mobile-title {
  margin: 0;
  font: 700 16px/1.4 var(--rbx-font-body);
}
@media (min-width: 1141px) {
  .rbx-sidebar-trigger {
    display: none;
  }
}
@media (prefers-reduced-motion: reduce) {
  .rbx-sidebar-popup,
  .rbx-sidebar-backdrop {
    transition: none;
  }
}

.rbx-sidebar-popup[data-ending-style],
.rbx-sidebar-backdrop[data-ending-style] {
  transition-timing-function: var(--rbx-motion-ease-exit);
}

목차