컴포넌트
Sidebar
페이지 탐색 메뉴를 표시합니다.
모션
열림과 닫힘 모두 500ms 동안 가장자리에서 이동합니다. 문서 사이트의 탐색 사이드바에도 같은 시간을 적용합니다. 움직임 감소 설정에서는 전환을 생략합니다. 공통 모션 규칙을 따릅니다.
설치
pnpm dlx ply-ui add sidebar badge button icon공통 설치 설정을 먼저 적용합니다.
사용법
고정 패널은 Root, 반응형은 Provider/Panel/Trigger를 사용합니다.
페이지 이동은 Link, 화면 안의 작업은 Action을 사용합니다. 활성 상태는 active로 지정합니다.
구성
| 구성 요소 | 역할 |
|---|---|
Root | 항상 표시하는 고정 사이드바입니다. |
Provider | 반응형 패널의 열림 상태를 관리합니다. open은 모바일 패널 상태입니다. |
Panel | 1140px 이하에서 모달 패널로, 넓은 화면에서는 고정 영역으로 표시합니다. |
Trigger | 모바일 패널을 엽니다. Provider 안에 배치합니다. |
Header / Content / Footer | 상단, 스크롤되는 본문, 하단 영역입니다. |
Group / GroupLabel | 탐색 항목을 묶고 이름을 표시합니다. |
Menu / Item | 탐색 목록과 각 행입니다. |
Link | 주소로 이동하는 NavigationLink입니다. |
Action | 화면 안의 작업을 실행하는 NavigationItem입니다. |
반응형 패널
Provider, Panel, Trigger를 함께 사용합니다. 새 창에서 너비를 바꾸면 고정 영역과 모바일 패널 전환을 확인할 수 있습니다.
구현 코드
components/ui/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
/* 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);
}