Search
๐Ÿ’ก

Sidebar

Created
2026/01/14 10:25
Tags
2026/01/18 12:31

Context

shadcn/ui๋ฅผ ํ™œ์šฉํ•ด Sidebar ์ปดํฌ๋„ŒํŠธ๋ฅผ ๊ตฌํ˜„ํ•˜๊ฒŒ ๋˜์—ˆ๋‹ค. Sidebar ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์šด๋กœ๋“œ ๋ฐ›์€ ํ›„ ์‚ดํŽด๋ณด๋‹ˆ ๋‚ด์šฉ์ด ๋งค์šฐ ๋งŽ๊ณ  ํ•œ ๋ˆˆ์— ์ดํ•ดํ•˜๊ธฐ์— ํ•œ๊ณ„๊ฐ€ ์žˆ์—ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ๋กœ์ปฌ์— ์žˆ๋Š” ์ฝ”๋“œ๋ฅผ ์‚ดํŽด๋ณด๋ฉด์„œ ๋‚ด์šฉ์„ ์ •๋ฆฌ ํ•ด๋ณด๊ธฐ ์œ„ํ•ด ์ด ๊ธ€์„ ์ž‘์„ฑํ•˜๊ฒŒ ๋˜์—ˆ๋‹ค.

Agenda

โ€ข
shadcn/ui์˜ Sidebar ์ปดํฌ๋„ŒํŠธ์™€ ๋ถ€๊ฐ€ ์š”์†Œ๋ฅผ ์‚ดํŽด๋ณด๊ณ  ์ฝ”๋“œ๋ฅผ ๋ถ„์„ ๋ฐ ์ดํ•ด

Contents

์œ„๋Š” shadcn/ui์—์„œ ์ œ๊ณตํ•˜๋Š” Sidebar ์ปดํฌ๋„ŒํŠธ์˜ ๊ตฌ์กฐ์ด๋‹ค.
์•„๋ž˜์˜ ๋ช…๋ น์–ด๋ฅผ ํ†ตํ•ด Sidebar ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์šด๋กœ๋“œํ•˜๋‹ˆ, ์—ฌ๋Ÿฌ ํŒŒ์ผ์ด ๋ ˆํฌ์ง€ํ† ๋ฆฌ์— ๋‹ค์šด๋กœ๋“œ ๋˜์—ˆ๋‹ค.
$ pnpm dlx shadcn@latest add sidebar
Shell
๋ณต์‚ฌ
๋‹ค์šด๋กœ๋“œ ๋œ ํŒŒ์ผ์€ ์—ฌ๋Ÿฌ๊ฐœ๋กœ Sidebar ์ปดํฌ๋„ŒํŠธ์—์„œ ์‚ฌ์šฉ ๋˜๋Š” ์ปดํฌ๋„ŒํŠธ์™€ ๋ถ€์ˆ˜์ ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์ปดํฌ๋„ŒํŠธ๋“ค์ด์—ˆ๋‹ค.
๊ณต์‹ ๋ฌธ์„œ์—์„œ ์•Œ๋ ค์ค€ ๋ฐฉ๋ฒ•๋Œ€๋กœ ์•„๋ž˜์˜ ํŒŒ์ผ์„ ๊ตฌ์„ฑํ•œ๋‹ค.
โ€ข
app-sidebar.tsx
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, } from "@/components/ui/sidebar" const AppSidebar = () => { return ( <Sidebar> <SidebarHeader /> <SidebarContent> <SidebarGroup /> <SidebarGroup /> </SidebarContent> <SidebarFooter /> </Sidebar> ); }; export default AppSidebar;
JavaScript
๋ณต์‚ฌ
โ€ข
layout.tsx
import { Outlet } from 'react-router-dom'; import AppSidebar from '@/components/app-sidebar'; import { SidebarProvider, SidebarTrigger } from '@/ui/components/sidebar'; const DevLayout = () => { return ( <SidebarProvider> <AppSidebar /> <SidebarTrigger /> <Outlet /> </SidebarProvider> ); }; export default DevLayout;
Shell
๋ณต์‚ฌ
layout.tsx๋Š” router๋ฅผ ํ†ตํ•ด ํ•˜์œ„ ํŽ˜์ด์ง€๋ฅผ ํ‘œํ˜„ํ•  ์˜ˆ์ •์ด๋ผ <Outlet />์„ ์‚ฌ์šฉํ–ˆ๋‹ค.
์ปจํ…์ธ ๋ฅผ ํ‘œ์‹œํ•˜๋Š” Layout์€ SidebarProvider๋กœ๋ถ€ํ„ฐ Outlet๊นŒ์ง€์˜ depth๋ฅผ ์ด๋ฃจ๊ณ  ์žˆ๋‹ค. Sidebar ์ปดํฌ๋„ŒํŠธ ํŒŒ์ผ์€ ์•ฝ 700์ค„์— ๋‹ฌํ•˜๋Š” ๋งค์šฐ ๊ธด ํŒŒ์ผ์ธ๋ฐ, SidebarProvider๊ฐ€ ์‚ฌ์ด๋“œ๋ฐ”์˜ ์ƒํƒœ, ๋ชจ๋ฐ”์ผ ๋ถ„๊ธฐ, ํ† ๊ธ€ ๋“ฑ์„ ๋ชจ๋‘ ๊ฒฐ์ •ํ•˜๋Š” ์ปจํŠธ๋กค ํƒ€์›Œ ์—ญํ• ์„ ํ•˜๊ณ  ์žˆ๋Š” ๊ฒƒ ๊ฐ™์•„ SidebarProvider๋ถ€ํ„ฐ ์‚ดํŽด๋ณด๊ธฐ๋กœ ํ–ˆ๋‹ค.
SidebarProvider
SidebarProvider์˜ ์‹คํ–‰๋ฌธ ์ฒซ ์ค„ ๋ถ€ํ„ฐ ์•„๋ž˜์˜ ๋ถ€๋ถ„์ด ๋ˆˆ์— ๋ˆ๋‹ค.
const isMobile = useIsMobile(); const [openMobile, setOpenMobile] = React.useState(false);
JavaScript
๋ณต์‚ฌ
๊ฐœ์ธ์ ์œผ๋กœ ์›น ํ”„๋กœ์ ํŠธ๋ฅผ ์ง„ํ–‰ํ•˜๋ฉด์„œ ๊ฐ€์žฅ ๊นŒ๋‹ค๋กญ๊ณ  ์–ด๋ ค์šด ๋ถ€๋ถ„์ด Mobile ๋ถ„๊ธฐ ๋ฐ ๋Œ€์‘์ด๋ผ๊ณ  ์ƒ๊ฐํ•˜๋Š”๋ฐ, shadcn์€ useIsMobile ํ›…์„ ํ†ตํ•ด ๋ชจ๋ฐ”์ผ ๋ถ„๊ธฐ๋ฅผ ์ฒ˜๋ฆฌํ–ˆ๋‹ค.
import { useEffect, useState } from 'react'; const MOBILE_BREAKPOINT = 768; export const useIsMobile = () => { const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined); useEffect(() => { const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const onChange = () => { setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); }; mql.addEventListener('change', onChange); setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); return () => mql.removeEventListener('change', onChange); }, []); return !!isMobile; };
JavaScript
๋ณต์‚ฌ
๋‚ด์šฉ์€ ์œ„์™€ ๊ฐ™๋‹ค. ๊ธฐ์กด ์ฝ”๋“œ๋Š” export function useIsMobile()๋กœ ์„ ์–ธ๋˜์–ด์žˆ์ง€๋งŒ, arrow function์„ ์‚ฌ์šฉํ•˜๋Š” ๋ฐฉํ–ฅ์ด ๋งˆ์Œ์— ๋“ค์–ด ์ˆ˜์ •ํ•˜์˜€๋‹ค. ์•ž์œผ๋กœ์˜ ์ฝ”๋“œ ๋˜ํ•œ arrow function์„ ์‚ฌ์šฉํ•œ๋‹ค.
const MOBILE_BREAKPOINT = 768;
JavaScript
๋ณต์‚ฌ
์ด ํ›…์€ 768์ด๋ผ๋Š” ๊ฐ’์„ ํ†ตํ•ด ๋ชจ๋ฐ”์ผ ๋ถ„๊ธฐ์ ์„ ์žก๋Š”๋‹ค. ๋„ˆ๋น„ 768 ๋ฏธ๋งŒ์€ ๋ชจ๋ฐ”์ผ, ๋„ˆ๋น„ 768 ์ด์ƒ์€ ๋ฐ์Šคํฌํƒ‘์œผ๋กœ ๋ชจ๋ฐ”์ผํ™”๋ฉด์ธ์ง€ ์•„๋‹Œ์ง€๋ฅผ ๊ตฌ๋ถ„ํ•œ๋‹ค.
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
JavaScript
๋ณต์‚ฌ
isMobile์€ ์ƒํƒœ ๊ฐ’์„ ์ €์žฅํ•˜๋Š” ๋ณ€์ˆ˜๋กœ, ์ดˆ๊ธฐ ๊ฐ’์€ undefined๋กœ ์ง€์ • ๋˜์–ด์žˆ๋‹ค. ํ™”๋ฉด์ด ๋ Œ๋”๋ง ๋˜๊ธฐ ์ „์—๋Š” ๋ชจ๋ฐ”์ผํ™”๋ฉด์ธ์ง€ ์•„๋‹Œ์ง€ ํŒ๋‹จํ•  ์ˆ˜ ์—†๊ธฐ ๋•Œ๋ฌธ์— undefined๋กœ ์ง€์ • ๋˜์–ด์žˆ๋‹ค.
useEffect(() => { const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const onChange = () => { setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); }; mql.addEventListener('change', onChange); setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); return () => mql.removeEventListener('change', onChange); }, []);
JavaScript
๋ณต์‚ฌ
useEffectํ›…์œผ๋กœ ํ™”๋ฉด์˜ ๋ Œ๋”๋ง์ด ์™„๋ฃŒ๋œ ํ›„ ์‹คํ–‰๋œ๋‹ค.
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
JavaScript
๋ณต์‚ฌ
window ์ „์—ญ ๊ฐ์ฒด์˜ matchMedia API๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ˜„์žฌ ํ™”๋ฉด์˜ ๋„ˆ๋น„๊ฐ€ ์ตœ๋Œ€ 767px ์ดํ•˜์˜ ์กฐ๊ฑด์„ ๋งŒ์กฑํ•˜๋Š”์ง€ ์•ˆํ•˜๋Š”์ง€ ํŒ๋‹จํ•œ๋‹ค. mql์€ Media Query List์˜ ์•ฝ์ž๋กœ 768 ๋ฏธ๋งŒ์˜ ์กฐ๊ฑด์„ ๊ฐ€์ง„ matchMedia ๊ฐ์ฒด๋ฅผ ๊ฐ–๋Š”๋‹ค.
const onChange = () => { setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); }; mql.addEventListener('change', onChange);
JavaScript
๋ณต์‚ฌ
onChange ํ•จ์ˆ˜๋Š” isMobile ์ƒํƒœ ๊ฐ’์„ ํ˜„์žฌ ๋„ˆ๋น„๊ฐ€ ๋ถ„๊ธฐ์ ๋ณด๋‹ค ์ž‘์œผ๋ฉด true ๊ฐ’์œผ๋กœ, ๊ฐ™๊ฑฐ๋‚˜ ํฌ๋ฉด false ๊ฐ’์œผ๋กœ ์ง€์ •ํ•œ๋‹ค. ์ด onChange ํ•จ์ˆ˜๋Š” mql ๊ฐ์ฒด์˜ change ์ด๋ฒคํŠธ์— ๋“ฑ๋ก ๋œ๋‹ค.
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
JavaScript
๋ณต์‚ฌ
์ด ์‹คํ–‰๋ฌธ์€ ํ™”๋ฉด์ด ์ฒ˜์Œ ๋ Œ๋”๋ง์ด ์™„๋ฃŒ๋œ ํ›„ ์‹คํ–‰๋˜๋Š” ๊ตฌ๋ฌธ์ด๋‹ค. ์ฒ˜์Œ isMobile ๊ฐ’์€ undefined ๊ฐ’์œผ๋กœ ๋ชจ๋ฐ”์ผ์ธ์ง€ ์•„๋‹Œ์ง€ ํŒ๋‹จํ•  ์ˆ˜ ์—†๋‹ค. ๋”ฐ๋ผ์„œ ์ตœ์ดˆ ํ™”๋ฉด ๋ Œ๋”๋ง ํ›„ ํ˜„์žฌ ํ™”๋ฉด ๋„ˆ๋น„์™€ ๋ถ„๊ธฐ๊ฐ’์˜ ํฌ๊ธฐ๋ฅผ ๋น„๊ตํ•˜์—ฌ ๊ฐ’์„ ํ• ๋‹นํ•œ๋‹ค.
return () => mql.removeEventListener('change', onChange);
JavaScript
๋ณต์‚ฌ
cleanup ํ•จ์ˆ˜๋กœ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์–ธ๋งˆ์šดํŠธ ๋˜๊ฑฐ๋‚˜ useEffect๊ฐ€ ๋‹ค์‹œ ์‹คํ–‰๋˜๊ธฐ ์ง์ „์— ์‹คํ–‰ ๋œ๋‹ค. ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜์™€ ์ค‘๋ณต ์‹คํ–‰์„ ๋ง‰๊ธฐ ์œ„ํ•ด ์ž‘์„ฑ ๋˜์—ˆ๋‹ค.
return !!isMobile;
JavaScript
๋ณต์‚ฌ
์ค‘์š”ํ•œ ๋ถ€๋ถ„์œผ๋กœ ํ˜„์žฌ Mobile ํ™”๋ฉด์ธ์ง€ ์•„๋‹Œ์ง€ ํŒ๋‹จํ•˜๋Š” ์ƒํƒœ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
๋‹ค์‹œ Sidebar ์ปดํฌ๋„ŒํŠธ์˜ SidebarProvider๋กœ ๋Œ์•„์™€์„œ,
// This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(defaultOpen); const open = openProp ?? _open; const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === 'function' ? value(open) : value; if (setOpenProp) { setOpenProp(openState); } else { _setOpen(openState); } // This sets the cookie to keep the sidebar state. document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; }, [setOpenProp, open], );
JavaScript
๋ณต์‚ฌ
์œ„์˜ ์ฝ”๋“œ๋Š” Sidebar์˜ ์—ด๋ฆผ/๋‹ซํž˜ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ๋ถ€๋ถ„์ด๋‹ค. SidebarProvider๋Š” ์™ธ๋ถ€์—์„œ Sidebar์˜ ์—ด๋ฆผ ๋‹ซํž˜์˜ ์ตœ์ดˆ ์ƒํƒœ๋ฅผ ๊ฒฐ์ •์ง€์„ ์ˆ˜ ์žˆ๊ณ  ์ปจํŠธ๋กค ๋˜ํ•œ ๊ฐ€๋Šฅํ•˜๊ฒŒ๋” ์„ค๊ณ„ ๋˜์–ด์žˆ๋‹ค.
โ€ข
defaultOpen์€ SidebarProvider์˜ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๊ธฐ๋ณธ ์—ด๋ฆผ/๋‹ซํž˜ ์ƒํƒœ๋ฅผ ์ง€์ •ํ•  ์ˆ˜ ์žˆ๋‹ค.
โ—ฆ
true๋ฉด ์—ด๋ฆผ, false๋ฉด ๋‹ซํž˜ ์ƒํƒœ์ด๋‹ค.
โ€ข
openProp์€ SidebarProvider์˜ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ Sidebar์˜ ์—ด๋ฆผ/๋‹ซํž˜ ์ƒํƒœ๋ฅผ ์ง€์ •ํ•˜๋Š” ๊ฐ’์ด๋‹ค. openProp์€ open์˜ aslias์ธ๋ฐ, defaultOpen ๊ฐ’์ด ์„ค์ • ๋˜์–ด์žˆ์–ด๋„ openProp ๊ฐ’์ด ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ฃผ์–ด์ง€๋ฉด openProp ๊ฐ’์„ ๋”ฐ๋ผ๊ฐ„๋‹ค.
โ€ข
setOpen์€ Sidebar์˜ ๋‚ด๋ถ€ ์—ด๋ฆผ/๋‹ซํž˜ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ํ•จ์ˆ˜์ด๋‹ค. useCallback ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ boolean | (value: boolean) => boolean ๋‘ ํƒ€์ž…์˜ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ํ—ˆ์šฉํ•˜๋Š”๋ฐ ์ด๋Š” setOpen(true), setOpen(false), setOpen(prev => !prev) ์™€ ๊ฐ™์€ ํ˜•ํƒœ ๋ชจ๋‘ ์ง€์›ํ•˜๊ธฐ ์œ„ํ•จ์ธ ๊ฒƒ์œผ๋กœ ์ถ”์ธกํ•œ๋‹ค. ์™ธ๋ถ€์—์„œ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ง€์ •ํ•œ setOpenProp ํ•จ์ˆ˜๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ ํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉ, ์—†์œผ๋ฉด ๋‚ด๋ถ€์— ์žˆ๋Š” setter๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค.
โ€ข
์‹ ๊ธฐํ•œ ๋ถ€๋ถ„์€ Cookie๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์—ด๋ฆผ/๋‹ซํž˜ ์ƒํƒœ๋ฅผ ์ €์žฅํ•˜๋Š” ๊ฒƒ์ธ๋ฐ, ์•„๋งˆ SSR์„ ์œ„ํ•ด ์ž‘์„ฑํ•ด๋‘” ๊ฒƒ ์•„๋‹๊นŒ ์ƒ๊ฐํ•œ๋‹ค. Next.js์™€ ๊ฐ™์€ ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ SSR์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š์„๊ฑฐ๋ผ๋ฉด local-storage์— ์ €์žฅํ•ด๋„ ์ƒ๊ด€ ์—†์„ ๊ฒƒ์œผ๋กœ ์˜ˆ์ƒํ•œ๋‹ค.
์•„๋ž˜๋Š” ๋ณ€๊ฒฝํ•œ ์ฝ”๋“œ.
... const SIDEBAR_STORAGE_NAME = 'sidebar_state'; ... // Internal state of the sidebar. // Use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = useState(defaultOpen); const open = openProp ?? _open; // Enable setOpen(true/false) and setOpen(prev => !prev) patterns. const setOpen = useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === 'function' ? value(open) : value; if (setOpenProp) { setOpenProp(openState); } else { _setOpen(openState); } // Save sidebar state in localStorage for persistence. localStorage.setItem(SIDEBAR_STORAGE_NAME, String(openState)); }, [setOpenProp, open], );
JavaScript
๋ณต์‚ฌ
์•„๋ž˜์˜ ์ฝ”๋“œ๋Š” Sidebar์˜ ์—ด๋ฆผ/๋‹ซํž˜ ์ƒํƒœ๋ฅผ ์กฐ์ž‘ํ•˜๋Š” ์ฝ”๋“œ์ด๋‹ค.
// Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); }, [isMobile, setOpen, setOpenMobile]); // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { event.preventDefault(); toggleSidebar(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [toggleSidebar]);
JavaScript
๋ณต์‚ฌ
// Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); }, [isMobile, setOpen, setOpenMobile]);
JavaScript
๋ณต์‚ฌ
toggleSidebar ํ•จ์ˆ˜๋Š” Mobile ํ™˜๊ฒฝ์ด๋ฉด setOpenMobile์„ ์‚ฌ์šฉํ•˜์—ฌ, ๋ฐ์Šคํฌํƒ‘ ํ™˜๊ฒฝ์ด๋ฉด setOpen์„ ์‚ฌ์šฉํ•˜์—ฌ Sidebar์˜ ์—ด๋ฆผ/๋‹ซํž˜ ์ƒํƒœ๋ฅผ ์ง€์ •ํ•œ๋‹ค. useCallback ํ•จ์ˆ˜๋กœ, ํ•จ์ˆ˜๋ฅผ ๋งค๋ฒˆ ์ƒˆ๋กœ ์ƒ์„ฑํ•˜์ง€ ์•Š๊ณ  ์˜์กด์„ฑ ๋ฐฐ์—ด ์•ˆ์— ์žˆ๋Š” ํ•ญ๋ชฉ๋“ค์ด ๋ณ€๊ฒฝ ๋˜์—ˆ์„ ๊ฒฝ์šฐ์— ๋‹ค์‹œ ํ•จ์ˆ˜๋ฅผ ๊ทธ๋ ค๋‚ธ๋‹ค.
... const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; ... // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { event.preventDefault(); toggleSidebar(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [toggleSidebar]);
JavaScript
๋ณต์‚ฌ
์ด hook์€ ๋‹จ์ถ•ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‚ฌ์ด๋“œ๋ฐ”๋ฅผ ์—ด๊ณ  ๋‹ซ์„ ์ˆ˜ ์žˆ๊ฒŒ ์„ค์ •ํ•˜๋Š” ๋ถ€๋ถ„์ด๋‹ค. ์ปจํŠธ๋กค + SIDEBAR_KEYBOARD_SHORTCUT์„ ๋ˆ„๋ฅด๋ฉด Sidebar์˜ ์—ด๋ฆผ/๋‹ซํž˜ ์ƒํƒœ๊ฐ€ ์ „ํ™˜๋œ๋‹ค.
// We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. const state = open ? 'expanded' : 'collapsed';
JavaScript
๋ณต์‚ฌ
boolean ํƒ€์ž…์ธ open ๊ฐ’์— ๋”ฐ๋ผ์„œ state๊ฐ€ expanded ๋˜๋Š” collapsed๋กœ ์„ค์ • ๋œ๋‹ค. HTML Element๋ฅผ ์‚ดํŽด๋ณด๋ฉด Sidebar์— data-state="expandedโ€ ํ˜•์‹์œผ๋กœ ์†์„ฑ์ด ์„ค์ • ๋˜์–ด์žˆ๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด Tailwind์—์„œ ์•„๋ž˜์™€ ๊ฐ™์ด ์‰ฝ๊ฒŒ ์Šคํƒ€์ผ๋ง์„ ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ๋‹ค.
className="data-[state=collapsed]:w-14 data-[state=expanded]:w-64"
JavaScript
๋ณต์‚ฌ
const contextValue = React.useMemo<SidebarContextProps>( () => ({ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, }), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], );
JavaScript
๋ณต์‚ฌ
Context์— ๋„˜๊ฒจ์ค„ ๊ณต์šฉ ์ƒํƒœ ๋ฌถ์Œ์„ ๋งŒ๋“œ๋Š” ์ฝ”๋“œ์ด๋‹ค. ์ด๋ฅผ ํ†ตํ•ด Context๋ฅผ ์“ฐ๋Š” ์ปดํฌ๋„ŒํŠธ๋Š” ์•„๋ž˜์™€ ๊ฐ™์ด Provider์—์„œ ๋„˜๊ฒจ์ฃผ๋Š” ๊ฐ’์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.
const { state, open, setOpen, isMobile, openMobile, toggleSidebar, } = useSidebar();
JavaScript
๋ณต์‚ฌ
์•„๋ž˜๋Š” ํ˜„์žฌ๊นŒ์ง€ ๋ณ€ํ™˜ ๋œ ์ฝ”๋“œ.
// Toggle function for sidebar. // If mobile, toggle mobile sidebar state. // If not mobile, toggle desktop sidebar state. const toggleSidebar = useCallback(() => { return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); }, [isMobile, setOpenMobile, setOpen]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.ctrlKey || event.metaKey)) { event.preventDefault(); toggleSidebar(); } }; // Add event listener for keyboard shortcut. window.addEventListener('keydown', handleKeyDown); // Cleanup event listener on unmount or when toggleSidebar changes. return () => window.removeEventListener('keydown', handleKeyDown); }, [toggleSidebar]); // Add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. const state = open ? 'expanded' : 'collapsed'; const contextValue = useMemo<SidebarContextProps>( () => ({ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, }), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], );
JavaScript
๋ณต์‚ฌ
<SidebarContext.Provider value={contextValue}> <TooltipProvider delayDuration={0}> <div data-slot='sidebar-wrapper' style={ { '--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, ...style, } as React.CSSProperties } className={cn( 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', className, )} {...props} > {children} </div> </TooltipProvider> </SidebarContext.Provider>
JavaScript
๋ณต์‚ฌ
Sidebar ์ „์ฒด ๋ ˆ์ด์•„์›ƒ๊ณผ ์ „์—ญ ํˆดํŒ ์„ค์ •, ์ปจํ…์ŠคํŠธ ๊ณต๊ธ‰์„ ํ•œ ๋ฒˆ์— ๊ฐ์‹ธ๋Š” ๊ตฌ์กฐ์ด๋‹ค. SidebarProvider์˜ ๋งˆ์ง€๋ง‰ ๋ถ€๋ถ„์ด๋‹ค. SidebarContext.Provider๋Š” Sidebar ๊ด€๋ จ ์ƒํƒœ/ํ•จ์ˆ˜๋ฅผ ํ•˜์œ„ ์ปดํฌ๋„ŒํŠธ๋“ค์ด ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค. ์ด Provider ์•ˆ์— ์žˆ๋Š” ๋ชจ๋“  children์€ Sidebar์˜ ์ƒํƒœ๋ฅผ ๊ณต์œ ํ•˜๊ฒŒ ๋œ๋‹ค.
TooltipProvider๋Š” Sidebar์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ Tooltip์˜ ๋™์ž‘์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค. ๊ด€๋ จ ๋ฌธ์„œ ์ฐธ๊ณ .
<div data-slot='sidebar-wrapper' style={ { '--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, ...style, } as React.CSSProperties } className={cn( 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', className, )} {...props} > {children} </div>
JavaScript
๋ณต์‚ฌ
์œ„ ์ฝ”๋“œ๋Š” ์‚ฌ์ด๋“œ๋ฐ”๋ฅผ ํฌํ•จํ•œ ๋ ˆ์ด์•„์›ƒ์„ ๊ฐ์‹ธ๋Š” wrapper์ด๋‹ค. data-slot์€ ์œ„์—์„œ ๋งํ•ด์™”๋˜๊ฒƒ์ฒ˜๋Ÿผ ์ปดํฌ๋„ŒํŠธ ์‹๋ณ„์šฉ์ด๋‹ค.
style={ { '--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, ...style, } as React.CSSProperties }
JavaScript
๋ณต์‚ฌ
๊ฐœ์ธ์ ์œผ๋กœ ์‹ ๊ฒฝ์“ฐ์˜€๋˜ ๋ถ€๋ถ„์€ ์ด ์ฝ”๋“œ์ด๋‹ค. ์ปจ๋ฒค์…˜์—์„œ ์ธ๋ผ์ธ์Šคํƒ€์ผ์€ ์“ฐ์ง€ ์•Š๊ธฐ๋กœ ํ–ˆ๋Š”๋ฐ, style ์†์„ฑ์ด ๋“ฑ์žฅํ•˜์—ฌ --sidebar-width์™€ --sidebar-width-icon์„ ํ• ๋‹นํ•œ๋‹ค. ๋”ฐ๋ผ์„œ data-* ์‹๋ณ„์ž๋ฅผ ํ™œ์šฉํ•ด mobile์ธ์ง€ desktopย ํ™”๋ฉด์ธ์ง€ ๊ตฌ๋ถ„ํ•˜์—ฌ css ํŒŒ์ผ์—์„œ ํ•ด๋‹น ๊ฐ’์„ ๋ณ€๊ฒฝํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์ˆ˜์ •ํ•˜์˜€๋‹ค.
<SidebarContext.Provider value={contextValue}> <TooltipProvider delayDuration={0}> <div data-slot='sidebar-wrapper' data-screen-type={isMobile ? 'mobile' : 'desktop'} className={cn( 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full ', className, )} {...props} > {children} </div> </TooltipProvider> </SidebarContext.Provider>
JavaScript
๋ณต์‚ฌ
/* desktop */ [data-slot="sidebar-wrapper"][data-screen-type="desktop"] { --sidebar-width: 16rem; --sidebar-width-icon: 3rem; } /* mobile */ [data-slot="sidebar-wrapper"][data-screen-type="mobile"] { --sidebar-width: 18rem; --sidebar-width-icon: 3rem; }
CSS
๋ณต์‚ฌ
Sidebar
ํ™”๋ฉด์— ๋ณด์—ฌ์ง€๋Š” Sidebar ์ปดํฌ๋„ŒํŠธ์ด๋‹ค.
function Sidebar({ side = 'left', variant = 'sidebar', collapsible = 'offcanvas', className, children, ...props }: React.ComponentProps<'div'> & { side?: 'left' | 'right'; variant?: 'sidebar' | 'floating' | 'inset'; collapsible?: 'offcanvas' | 'icon' | 'none'; }) { ... }
JavaScript
๋ณต์‚ฌ
์œ„์˜ ์ฝ”๋“œ๋Š” Sidebar ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” ํŒŒ๋ผ๋ฏธํ„ฐ์™€ ๊ทธ ํƒ€์ž…์ด๋‹ค. side ํŒŒ๋ผ๋ฏธํ„ฐ๋Š” Sidebar๊ฐ€ ์œ„์น˜ํ•  ๊ณณ์„ ์ง€์ •ํ•˜๋ฉฐ, left ๋˜๋Š” right๋กœ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. variant๋Š” Sidebar๊ฐ€ ๋ณด์ด๋Š” ํ˜•ํƒœ์ธ๋ฐ sidebar๋Š” ๋ฐฐ๊ฒฝ์ƒ‰์ด ์žˆ์œผ๋ฉด์„œ ์™„์ „ํžˆ ์˜์—ญ ์œ„, ์•„๋ž˜, ์˜†๋ฉด์ด ๋ถ™์–ด์žˆ๋‹ค. floating์œผ๋กœ ์„ค์ •ํ•˜๋ฉด padding์ด ์žˆ๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ๋‚˜ํƒ€๋‚˜๋ฉฐ inset์€ ๋ฐฐ๊ฒฝ์ƒ‰์ด ์—†์–ด์ง€๊ณ  ๊ฒฝ๊ณ„ ๋˜ํ•œ ์‚ฌ๋ผ์ ธ๋ณด์ธ๋‹ค. collapsible ํŒŒ๋ผ๋ฏธํ„ฐ๋Š” offcanvas, icon, none ์„ธ ๊ฐ€์ง€ ๊ฐ’์„ ๋ฐ›์„ ์ˆ˜ ์žˆ๋‹ค. offcanvas๋Š” ์‚ฌ์ด๋“œ๋ฐ”๋ฅผ ์ ‘๊ณ  ํŽผ์น  ์ˆ˜ ์žˆ์œผ๋ฉฐ ์ ‘์—ˆ์„ ๋•Œ ์™„์ „ํžˆ ์ ‘ํ˜€์„œ ์‚ฌ์ด๋“œ๋ฐ”๊ฐ€ ๋ณด์ด์ง€ ์•Š๋Š”๋‹ค. icon์œผ๋กœ ์„ค์ •์‹œ ์ ‘๊ณ  ํŽผ์น  ์ˆ˜ ์žˆ๊ณ  ์ ‘์—ˆ์„ ๋•Œ ์•„์ด์ฝ˜ ์˜์—ญ์ด ๋ณด์ธ๋‹ค. none์œผ๋กœ ์„ค์ •์‹œ ์ ‘์„ ์ˆ˜ ์—†๋‹ค.
const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); if (collapsible === 'none') { return ( <div data-slot='sidebar' className={cn( 'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', className, )} {...props} > {children} </div> ); }
JavaScript
๋ณต์‚ฌ
๋จผ์ €, useSidebar ํ›…์„ ํ†ตํ•ด SidebarProvider์—์„œ ์ œ๊ณตํ•˜๋Š” ๊ฐ’์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  sidebar์˜ collapsible ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ none์ผ ๋•Œ, sidebar๋Š” ์ ‘์„ ์ˆ˜ ์—†๋‹ค. ๋”ฐ๋ผ์„œ ๋ถ„๊ธฐ๋ฌธ์„ ํ†ตํ•ด ๊ณ ์ • ๋„ˆ๋น„์˜ ๊ณต๊ฐ„์„ ๋งŒ๋“ค์–ด ์ปจํ…์ธ ๋ฅผ ํ‘œ์‹œํ•œ๋‹ค.
if (isMobile) { return ( <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> <SheetContent data-sidebar='sidebar' data-slot='sidebar' data-mobile='true' className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden' style={ { '--sidebar-width': SIDEBAR_WIDTH_MOBILE, } as React.CSSProperties } side={side} > <SheetHeader className='sr-only'> <SheetTitle>Sidebar</SheetTitle> <SheetDescription>Displays the mobile sidebar.</SheetDescription> </SheetHeader> <div className='flex h-full w-full flex-col'>{children}</div> </SheetContent> </Sheet> ); }
JavaScript
๋ณต์‚ฌ
Mobile์ผ ํ™”๋ฉด์ผ ๋•Œ ๋ Œ๋”๋ง ๋˜๋Š” ์‚ฌ์ด๋“œ๋ฐ”์ด๋‹ค. radix-ui์˜ wrapper ์„ฑ๊ฒฉ์˜ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ Mobile ์‚ฌ์ด๋“œ๋ฐ”๋ฅผ ๋ถ„๊ธฐ๋กœ ์ฒ˜๋ฆฌํ•ด ํ™”๋ฉด์— ๋ Œ๋”๋ง ํ•œ๋‹ค. ํƒœ๊ทธ ์•ˆ์— style ํƒœ๊ทธ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์‹ซ์–ด์„œ ์•„๋ž˜์™€ ๊ฐ™์ด ์ˆ˜์ •ํ•˜์˜€๋‹ค.
if (isMobile) { return ( <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> <SheetContent data-sidebar='sidebar' data-slot='sidebar' data-mobile='true' className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden' side={side} > <SheetHeader className='sr-only'> <SheetTitle>Sidebar</SheetTitle> <SheetDescription>Displays the mobile sidebar.</SheetDescription> </SheetHeader> <div className='flex h-full w-full flex-col'>{children}</div> </SheetContent> </Sheet> ); }
JavaScript
๋ณต์‚ฌ
CSS ํŒŒ์ผ์— mobile ๋Œ€์ƒ์„ ํ•˜๋‚˜ ๋” ์ถ”๊ฐ€ํ–ˆ๋‹ค. [data-slot="sidebar"][data-mobile="true"] ์ด ๋ถ€๋ถ„์ธ๋ฐ, Mobile์ผ ๋•Œ Sheet์˜ Portal์„ ์‚ฌ์šฉํ•˜์—ฌ ๊ตฌ์กฐ์ ์œผ๋กœ ์™ธ๋ถ€์— ๋ Œ๋”๋ง ๋˜๊ธฐ ๋•Œ๋ฌธ์— --sidebar-width๊ฐ€ ์ ์šฉ ๋˜์ง€ ์•Š์•„์„œ์ด๋‹ค.
/* desktop */ [data-slot="sidebar-wrapper"][data-screen-type="desktop"] { --sidebar-width: 16rem; --sidebar-width-icon: 3rem; } /* mobile */ [data-slot="sidebar-wrapper"][data-screen-type="mobile"], [data-slot="sidebar"][data-mobile="true"] { --sidebar-width: 18rem; --sidebar-width-icon: 3rem; }
CSS
๋ณต์‚ฌ
<div className='group peer text-sidebar-foreground hidden md:block' data-state={state} data-collapsible={state === 'collapsed' ? collapsible : ''} data-variant={variant} data-side={side} data-slot='sidebar' > ... </div>
JavaScript
๋ณต์‚ฌ
Desktop ํ™˜๊ฒฝ์—์„œ ๋ Œ๋”๋ง ๋˜๋Š” ์‚ฌ์ด๋“œ๋ฐ” ์ปดํฌ๋„ŒํŠธ์˜ ์‹œ์ž‘ ๋ถ€๋ถ„์ด๋‹ค. className์„ ํ†ตํ•ด ์‚ฌ์ด๋“œ๋ฐ”์˜ ์Šคํƒ€์ผ์„ ์ง€์ •ํ•˜๊ณ  ์žˆ๋‹ค. group์€ ์ž์‹ ์š”์†Œ๊ฐ€ group-* ๋ฐฉ์‹์œผ๋กœ ๋ถ€๋ชจ์˜ data-* ๊ฐ’์„ ์ฝ์–ด ์Šคํƒ€์ผ๋ง์„ ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค. peer๋Š” group๊ณผ ๋น„์Šทํ•˜๊ฒŒ ํ˜•์ œ ์ปดํฌ๋„ŒํŠธ๊ฐ€ peer-* ๋ฐฉ์‹์œผ๋กœ ์ด div์˜ data-* ๊ฐ’์„ ์ฝ์–ด ์Šคํƒ€์ผ๋ง์„ ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค. hidden md:block์€ ๋ชจ๋ฐ”์ผ์—์„œ๋Š” ์ˆจ๊ธฐ๊ณ  md ์‚ฌ์ด์ฆˆ ์ด์ƒ์ผ ๋•Œ์— ํ™”๋ฉด์— ํ‘œ์‹œ๋˜๊ฒŒ ํ•œ๋‹ค.
{/* This is what handles the sidebar gap on desktop */} <div data-slot='sidebar-gap' className={cn( 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', 'group-data-[collapsible=offcanvas]:w-0', 'group-data-[side=right]:rotate-180', variant === 'floating' || variant === 'inset' ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)', )} />
JavaScript
๋ณต์‚ฌ
์œ„ ์ฝ”๋“œ๋Š” Sidebar๊ฐ€ ์ฐจ์ง€ํ•˜๋Š” ๊ณต๊ฐ„์„ ์ง€์ •ํ•˜๋Š” ์ฝ”๋“œ์ด๋‹ค. Sidebar๋Š” Gap ๋ ˆ์ด์–ด์™€ ์‹ค์ œ ์ปจํ…์ธ ๋ฅผ ๋‹ด๋Š” ์ปจํ…Œ์ด๋„ˆ ๋ ˆ์ด์–ด๋ฅผ ๋ถ„๋ฆฌํ•ด์„œ ์‚ฌ์šฉํ•œ๋‹ค. ๊ณผ๊ฑฐ ์‚ฌ์ด๋“œ๋ฐ”๋ฅผ ๊ตฌํ˜„ํ–ˆ์„ ๋•Œ sidebar wrapper์— ์ง์ ‘ width๋ฅผ ์ฃผ์–ด sidebar๊ฐ€ ํŽผ์ณ์ง€๊ณ  ์ ‘ํž ๋•Œ์˜ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์ฒ˜๋ฆฌ ํ–ˆ์—ˆ๋Š”๋ฐ, shadcn/ui๋Š” Gap ๋ ˆ์ด์–ด๋ฅผ ๋ณ„๋„๋กœ ๋‘์–ด ์ปจํ…์ธ  ์˜์—ญ์„ ํ™•๋ณด ํ•œ๋‹ค๋Š” ์ ์ด ์‹ ๊ธฐํ•˜๋‹ค. ๋‹ค์Œ์— ์ž๋ ฅ์œผ๋กœ ๊ตฌํ˜„ ํ•  ๋•Œ ์จ๋จน์–ด๋ณด๊ณ ์‹ถ์€ ์Šคํ‚ฌ์ด๋‹ค.
transition-[width]๋Š” width ๊ฐ’์ด ๋ณ€๊ฒฝ๋  ๋•Œ ๊ทธ ๋ณ€ํ™”๋ฅผ ์• ๋‹ˆ๋ฉ”์ด์…˜์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๋ผ๋Š” ๊ฐ’์ด๋‹ค. group-data-[collapsible=offcanvas]:w-0์€ ๋ถ€๋ชจ ์š”์†Œ์˜ data-collapsible ๊ฐ’์ด offcanvas๋ฉด width๋ฅผ 0์œผ๋กœ ์„ค์ •ํ•œ๋‹ค. group-data-[side=right]:rotate-180์€ ๋ถ€๋ชจ ์š”์†Œ์˜ data-side ๊ฐ’์ด right์ผ ๋•Œ rotate-180์„ ์ ์šฉํ•œ๋‹ค. variant ๊ฐ’์ด floating์ด๊ฑฐ๋‚˜ inset์ผ ๋•Œ data-collapsible์ด icon์ผ ๊ฒฝ์šฐ ์ฃผ์–ด์ง„ ๋„ˆ๋น„์—์„œ spacing์„ ์ถ”๊ฐ€ํ•œ๋‹ค. ๋งŒ์•ฝ์— variant ๊ฐ’์ด ์ด ์™ธ์˜ ๊ฐ’์ผ ๊ฒฝ์šฐ spacing์„ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š๋Š”๋‹ค.
{/* Sidebar content */} <div data-slot='sidebar-container' className={cn( 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex', side === 'left' ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', // Adjust the padding for floating and inset variants. variant === 'floating' || variant === 'inset' ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l', className, )} {...props} > ... </div>
JavaScript
๋ณต์‚ฌ
Sidebar ๊ณต๊ฐ„์„ ํ™•๋ณดํ•˜๊ธฐ ์œ„ํ•ด Gap ๋ ˆ์ด์–ด๊ฐ€ ์žˆ์—ˆ๋‹ค๋ฉด ์œ„์˜ ์ฝ”๋“œ๋Š” ์‹ค์ œ ์‚ฌ์ด๋“œ๋ฐ”๊ฐ€ ๋ Œ๋”๋ง ๋˜๋Š” ์ปจํ…Œ์ด๋„ˆ ๋ถ€๋ถ„์ด๋‹ค. ์‚ฌ์ด๋“œ๋ฐ”๊ฐ€ ๋ Œ๋”๋ง ๋˜๋Š” ์œ„์น˜์™€ variant์— ๋”ฐ๋ผ ์กฐ๊ฑด๋ถ€ ์Šคํƒ€์ผ๋ง์ด ์ ์šฉ๋˜์–ด ์žˆ๋‹ค. ํŠน์ดํ•œ ๋ถ€๋ถ„์€ variant์˜ ์กฐ๊ฑด์ด ๋งŒ์กฑํ•  ๋•Œ +2px๋ฅผ ํ•˜๋Š” ๋ถ€๋ถ„์ธ๋ฐ, ์•„๋งˆ ์•„๋ž˜์˜ sidebar-inner์—์„œ border ๋•Œ๋ฌธ์— 1px * 2 ํ•ด์„œ 2px๋งŒํผ์˜ ๋„ˆ๋น„๋ฅผ ๋” ๋ถ€์—ฌํ•˜๋Š” ๊ฒƒ ๊ฐ™๋‹ค.
<div data-sidebar='sidebar' data-slot='sidebar-inner' className={cn( 'bg-sidebar flex h-full w-full flex-col', 'group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm', )} > {children} </div>
JavaScript
๋ณต์‚ฌ
Sidebar ์ปจํ…์ธ ๋ฅผ ๋ Œ๋”๋งํ•˜๋Š” ์ฝ”๋“œ์ด๋‹ค. ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ variant์˜ ์กฐ๊ฑด์ด ๋งŒ์กฑํ•  ๊ฒฝ์šฐ ์กฐ๊ฑด๋ถ€ ์Šคํƒ€์ผ๋ง์„ ์ ์šฉํ•œ๋‹ค.
SidebarTrigger
const SidebarTrigger = ({ className, onClick, ...props }: React.ComponentProps<typeof Button>) => { const { toggleSidebar } = useSidebar(); return ( <Button data-sidebar='trigger' data-slot='sidebar-trigger' variant='ghost' size='icon' className={cn('size-7', className)} onClick={(event) => { onClick?.(event); toggleSidebar(); }} {...props} > <PanelLeftIcon /> <span className='sr-only'>Toggle Sidebar</span> </Button> ); };
TypeScript
๋ณต์‚ฌ
Sidebar๋ฅผ ์—ด๊ณ  ๋‹ซ์„ ์ˆ˜ ์žˆ๋Š” Trigger ์ปดํฌ๋„ŒํŠธ์ด๋‹ค. shadcn/ui์˜ Button์„ ๊ฐ€์ ธ์™€์„œ ํ™œ์šฉํ•œ๋‹ค.
SidebarRail
const SidebarRail = ({ className, ...props }: React.ComponentProps<'button'>) => { const { toggleSidebar } = useSidebar(); return ( <button data-sidebar='rail' data-slot='sidebar-rail' aria-label='Toggle Sidebar' tabIndex={-1} onClick={toggleSidebar} title='Toggle Sidebar' className={cn( 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear hover:after:bg-sidebar-border', 'group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex', 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize', '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', 'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full', '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', className, )} {...props} /> ); };
TypeScript
๋ณต์‚ฌ
SliderRail์€ Trigger์ฒ˜๋Ÿผ ์ž‘๋™ํ•˜๋Š” Slider border์— ๋ถ™์–ด์žˆ๋Š” ์ปดํฌ๋„ŒํŠธ์ด๋‹ค. ๋งˆ์šฐ์Šค๋ฅผ ์˜ฌ๋ฆฌ๋ฉด ์„ธ๋กœ์„ ์ด ํ…Œ๋‘๋ฆฌ์— ์ƒ๊ธฐ๋Š” ์ปดํฌ๋„ŒํŠธ๋กœ ์ƒ๊ฐํ•˜๋ฉด ๋œ๋‹ค. ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ useSidebar ํ›…์„ ํ™œ์šฉํ•˜์—ฌ Sidebar์˜ ํŽผ์นจ ์ƒํƒœ๋ฅผ ํ† ๊ธ€ํ•œ๋‹ค. tabIndex๋ฅผ -1๋กœ ์ฃผ์–ด ํ‚ค๋ณด๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ด๋™ํ•˜๊ฑฐ๋‚˜ ํฌ์ปค์Šค๋ฅผ ์ค„ ๋•Œ ํฌ์ปค์Šค๊ฐ€ ์žกํžˆ์ง€ ์•Š๋„๋ก ์ง€์ •ํ–ˆ๋‹ค. data-slot์ด ์žˆ์œผ๋ฉด aria-label์ด ํ•„์š” ์—†๋‹ค๊ณ  ์ƒ๊ฐํ–ˆ๋Š”๋ฐ, ์ฐพ์•„๋ณด๋‹ˆ aria-label์€ ์ ‘๊ทผ์„ฑ ์ฐจ์›์—์„œ ํ•„์š”ํ•œ ๊ฒƒ์ด๊ณ  data-* ํ˜•ํƒœ๋Š” ์ปค์Šคํ„ฐ๋งˆ์ด์ง• ์†์„ฑ์ด์–ด์„œ ๋‹ค๋ฅธ ์„ฑ๊ฒฉ์„ ๋ˆ๋‹ค.
์ œ์ผ ๋ณต์žกํ•œ ๋ถ€๋ถ„์€ ์Šคํƒ€์ผ๋ง ๋ถ€๋ถ„์ธ๋ฐ, ์‚ฌ์ด๋“œ๋ฐ”๋ฅผ ํŽผ์ณค์„ ๋•Œ์™€ ์ ‘์—ˆ์„ ๋•Œ SidebarRail์ด ๋ณด์ด๋Š” ์˜์—ญ์˜ ๋„ˆ๋น„๋ฅผ ๊ณ„์‚ฐํ•˜๋А๋ผ ์ข€ ์• ๋จน์—ˆ๋‹ค. ์Šคํƒ€์ผ๋ง์ด ๋ณต์žกํ•˜๊ฒŒ ๋˜์–ด์žˆ์œผ๋‚˜, ํ•˜๋‚˜ํ•˜๋‚˜ ํŒŒ์•…ํ•ด๋‚˜๊ฐ€๋ฉด ์ดํ•ด๋ฅผ ํ•  ์ˆ˜ ์žˆ๋‹ค. ์ถ”๊ฐ€๋กœ group-data-*๊ผด๊ณผ in-data-*๊ผด์˜ ์ ‘๊ทผ์ž๊ฐ€ ์žˆ๋Š”๋ฐ, ์ฐพ์•„๋ณด๋‹ˆ ๋‘˜์˜ ๊ธฐ๋Šฅ์€ ์œ ์‚ฌํ•˜์ง€๋งŒ group๊ณผ ๋‹ฌ๋ฆฌ in์€ ๋ถ€๋ชจ ์š”์†Œ์— group ๊ฐ’์ด ์—†์–ด๋„ group์ฒ˜๋Ÿผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. ์ฐธ๊ณ  ์ž๋ฃŒ
SidebarInset
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) { return ( <main data-slot='sidebar-inset' className={cn( 'bg-background relative flex w-full flex-1 flex-col', 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', className, )} {...props} /> ); }
TypeScript
๋ณต์‚ฌ
Sidebar์˜ variant๊ฐ€ inset์ผ ๊ฒฝ์šฐ ์‚ฌ์ด๋“œ๋ฐ”์˜ ๋ฐฐ๊ฒฝ์ด ์—†์–ด์ง€๊ณ  ํ”Œ๋žซํ•œ ํ˜•ํƒœ๊ฐ€ ๋œ๋‹ค. SidebarInset์€ ์‚ฌ์ด๋“œ๋ฐ” ์šฐ์ธก์— ๋ฉ”์ธ ์ปจํ…์ธ ๋ฅผ ํ‘œ์‹œํ•  ๋•Œ ์‚ฌ์šฉํ•˜๋Š” Wrapper์˜ ์—ญํ• ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค. variant๊ฐ€ inset์ผ ๊ฒฝ์šฐ์—๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ์€ ์•„๋‹Œ ๊ฒƒ ๊ฐ™์€๋ฐ, ์Šคํƒ€์ผ๋ง์— peer-data-[variant=inset] ํ˜•ํƒœ๊ฐ€ ๋Œ€๋ถ€๋ถ„์ธ ๊ฒƒ์„ ๋ณด์•„ inset ์ƒํƒœ์—์„œ ์ฃผ๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์œผ๋กœ ๋ณด์ธ๋‹ค.
SidebarInput
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) { return ( <Input data-slot='sidebar-input' data-sidebar='input' className={cn('bg-background h-8 w-full shadow-none', className)} {...props} /> ); }
TypeScript
๋ณต์‚ฌ
Sidebar์—์„œ ์‚ฌ์šฉํ•˜๋Š” Input ์ปดํฌ๋„ŒํŠธ์ด๋‹ค. shadcn/ui์˜ Input ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ™œ์šฉํ•˜์—ฌ Sidebar์—์„œ ์‚ฌ์šฉํ•˜๋Š” Input์„ ์žฌ์Šคํƒ€์ผ๋ง ํ–ˆ๋‹ค.
SidebarHeader
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot='sidebar-header' data-sidebar='header' className={cn('flex flex-col gap-2 p-2', className)} {...props} /> ); }
TypeScript
๋ณต์‚ฌ
Sidebar ์ปดํฌ๋„ŒํŠธ์˜ ์ตœ์ƒ๋‹จ์— ํ‘œ์‹œ๋˜๋Š” SidebarHeader ์ปดํฌ๋„ŒํŠธ์ด๋‹ค. ์ž์‹ ์š”์†Œ๋“ค์„ ์„ธ๋กœ๋กœ ์ •๋ ฌํ•˜๊ณ  ์‚ฌ์ด์˜ ๊ฐ„๊ฒฉ๊ณผ ๋‚ด๋ถ€ ํŒจ๋”ฉ์„ ๋ถ€์—ฌํ•œ๋‹ค.
SidebarFooter
const SidebarFooter = ({ className, ...props }: React.ComponentProps<'div'>) => { return ( <div data-slot='sidebar-footer' data-sidebar='footer' className={cn('flex flex-col gap-2 p-2', className)} {...props} /> ); };
TypeScript
๋ณต์‚ฌ
Sidebar ์ปดํฌ๋„ŒํŠธ์˜ ์ตœํ•˜๋‹จ์— ํ‘œ์‹œ๋˜๋Š” SidebarFooter ์ปดํฌ๋„ŒํŠธ์ด๋‹ค. ์ž์‹ ์š”์†Œ๋“ค์„ ์„ธ๋กœ๋กœ ์ •๋ ฌํ•˜๊ณ  ์‚ฌ์ด์˜ ๊ฐ„๊ฒฉ๊ณผ ๋‚ด๋ถ€ ํŒจ๋”ฉ์„ ๋ถ€์—ฌํ•œ๋‹ค. data-* ์†์„ฑ ๊ฐ’์„ ์ œ์™ธํ•˜๊ณ ๋Š” SidebarHeader์™€ ๊ตฌํ˜„ ์ฐจ์ด์ ์ด ์—†๋‹ค.
SidebarContent
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot='sidebar-content' data-sidebar='content' className={cn( 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', className, )} {...props} /> ); }
TypeScript
๋ณต์‚ฌ
Sidebar ์ปจํ…์ธ ๊ฐ€ ํ‘œ์‹œ๋˜๋Š” ์˜์—ญ์ด๋‹ค. flex, min-h-0, overflow-auto๋ฅผ ํ†ตํ•ด ์Šคํฌ๋กค๋ฐ” ์ „์ฒด๊ฐ€ ์•„๋‹Œ ์ปจํ…์ธ  ์˜์—ญ๋งŒ ์Šคํฌ๋กค์ด ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋˜์–ด์žˆ๋‹ค.
SidebarGroup
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot='sidebar-group' data-sidebar='group' className={cn('relative flex w-full min-w-0 flex-col p-2', className)} {...props} /> ); }
TypeScript
๋ณต์‚ฌ
Sidebar ์ปจํ…์ธ ๋ฅผ ๊ทธ๋ฃน์œผ๋กœ ํ‘œ์‹œํ•˜๊ณ  ์‹ถ์„ ๋•Œ ์•„์ดํ…œ์„ ๊ฐ์‹ธ๋Š” ์˜์—ญ์ด๋‹ค. relative๋ฅผ ํ†ตํ•ด ์•ˆ์— absolute ํฌ์ง€์…˜ ์š”์†Œ๋ฅผ ๋„ฃ์„ ์ˆ˜ ์žˆ๋‹ค.
SidebarGroupAction
function SidebarGroupAction({ className, asChild = false, ...props }: React.ComponentProps<'button'> & { asChild?: boolean }) { const Comp = asChild ? Slot : 'button'; return ( <Comp data-slot='sidebar-group-action' data-sidebar='group-action' className={cn( 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', // Increases the hit area of the button on mobile. 'after:absolute after:-inset-2 md:after:hidden', 'group-data-[collapsible=icon]:hidden', className, )} {...props} /> ); }
TypeScript
๋ณต์‚ฌ
SidebarGroup์˜ ์šฐ์ธก์— ๋ถ™๋Š” ๋ฒ„ํŠผ ํŠธ๋ฆฌ๊ฑฐ์ด๋‹ค. absolute, top-3.5, right-3์„ ํ†ตํ•ด ์šฐ์ƒ๋‹จ์— ๋ Œ๋”๋ง ๋˜๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ๋‹ค.
SidebarGroupContent
function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot='sidebar-group-content' data-sidebar='group-content' className={cn('w-full text-sm', className)} {...props} /> ); }
TypeScript
๋ณต์‚ฌ
SidebarGroup ์•ˆ์—์„œ ์‹ค์ œ ์ปจํ…์ธ ๋ฅผ ๋‹ด๋Š” ์˜์—ญ์„ ๋‹ด๋‹นํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์ด๋‹ค.
SidebarMenu & SidebarMenuItem
const SidebarMenu = ({ className, ...props }: React.ComponentProps<'ul'>) => { return ( <ul data-slot='sidebar-menu' data-sidebar='menu' className={cn('flex w-full min-w-0 flex-col gap-1', className)} {...props} /> ); }; const SidebarMenuItem = ({ className, ...props }: React.ComponentProps<'li'>) => { return ( <li data-slot='sidebar-menu-item' data-sidebar='menu-item' className={cn('group/menu-item relative', className)} {...props} /> ); };
TypeScript
๋ณต์‚ฌ
Sidebar์— ํ‘œ์‹œ ๋˜๋Š” ๋ฉ”๋‰ด์™€ ๊ทธ ๋ฉ”๋‰ด ์•ˆ์— ๋“ค์–ด๊ฐ€๋Š” ์•„์ดํ…œ์— ๋Œ€ํ•œ ์ปดํฌ๋„ŒํŠธ์ด๋‹ค. SidebarMenuItem์˜ ๊ฒฝ์šฐ group/menu-item๊ณผ relative ์†์„ฑ์„ ํ†ตํ•ด ์ž์‹ ์ปดํฌ๋„ŒํŠธ์—์„œ SidebarMenuItem์˜ ์ด๋ฒคํŠธ๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์Šคํƒ€์ผ๋ง์„ ํ•  ์ˆ˜ ์žˆ๊ณ  ๋‚ด๋ถ€์— absolute ์š”์†Œ๋ฅผ ๋ฐฐ์น˜ํ•  ์ˆ˜ ์žˆ๋‹ค.
SidebarMenuButton
SidebarMenuButton์€ SidebarMenuItem์—์„œ ๋ Œ๋”๋ง ๋˜๋Š” ์ปจํ…์ธ ์™€๋„ ๊ฐ™๋‹ค. ์ฒ˜์Œ์— Button์ด๋ผ๊ณ  ํ•ด์„œ ์ž‘์€ ๋ฒ„ํŠผ์ธ์ค„ ์•Œ์•˜๋Š”๋ฐ MenuItem์— ๋ Œ๋”๋ง ๋˜๋Š” ์ „์ฒด๊ฐ€ ๋ฒ„ํŠผ์ฒ˜๋Ÿผ ๋™์ž‘ํ•œ๋‹ค.
const sidebarMenuButtonVariants = cva( 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', { variants: { variant: { default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', outline: 'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]', }, size: { default: 'h-8 text-sm', sm: 'h-7 text-xs', lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!', }, }, defaultVariants: { variant: 'default', size: 'default', }, }, );
JavaScript
๋ณต์‚ฌ
cva๋ฅผ ํ†ตํ•ด ์Šคํƒ€์ผ๋ง์„ ํ•จ์ˆ˜ํ™” ํ•˜์—ฌ ์‚ฌ์šฉํ•œ๋‹ค. ์ด๋Š” sidebarMenuButtonVariants({variant, size}) ํ˜•์‹์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค.
const Comp = asChild ? Slot : 'button'; const { isMobile, state } = useSidebar(); const button = ( <Comp data-slot='sidebar-menu-button' data-sidebar='menu-button' data-size={size} data-active={isActive} className={cn(sidebarMenuButtonVariants({ variant, size }), className)} {...props} /> ); if (!tooltip) { return button; }
TypeScript
๋ณต์‚ฌ
์ด ์ปดํฌ๋„ŒํŠธ๋Š” tooltip ์—ฌ๋ถ€๋ฅผ ํŒ๋‹จํ•˜์—ฌ ๋จผ์ € ์กฐ๊ฑด๋ถ€ ๋ Œ๋”๋ง์„ ํ•œ๋‹ค. tooltip์ด ์—†์„ ๋•Œ์—๋Š” ์•„๋ž˜์„œ ๋‚˜์˜ฌ tooltip ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ƒ์„ฑํ•˜์ง€ ์•Š๊ณ  ์œ„์—์„œ ๋ฏธ๋ฆฌ ๋งŒ๋“ค์–ด๋‘์—ˆ๋˜ variants๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์Šคํƒ€์ผ๋ง์„ ํ•œ๋‹ค.
if (typeof tooltip === 'string') { tooltip = { children: tooltip, }; } return ( <Tooltip> <TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipContent side='right' align='center' hidden={state !== 'collapsed' || isMobile} {...tooltip} /> </Tooltip> );
TypeScript
๋ณต์‚ฌ
๋งŒ์•ฝ <SidebarMenuButton tooltip=โ€™Tooltipโ€™ /> ํ˜•์‹์œผ๋กœ tooltip์ด string์ด๋ผ๋ฉด ๊ตฌ์กฐ ํ†ต์ผ์„ ์œ„ํ•ด tooltip์„ children์— ๋„ฃ๊ณ  ๊ฐ์ฒดํ™”ํ•œ๋‹ค. ๊ทธ๋ฆฌ๊ณ  tooltip์ด ์žˆ์œผ๋ฉด shadcn/ui์˜ Tooltip ์ปดํฌ๋„ŒํŠธ์™€ ๊ทธ ํ•˜์œ„ ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์œ„์˜ MenuItem(button)๊ณผ tooltip์„ ํ‘œ์‹œํ•œ๋‹ค.
SidebarMenuAction
function SidebarMenuAction({ className, asChild = false, showOnHover = false, ...props }: React.ComponentProps<'button'> & { asChild?: boolean; showOnHover?: boolean; }) { const Comp = asChild ? Slot : 'button'; return ( <Comp data-slot='sidebar-menu-action' data-sidebar='menu-action' className={cn( 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', // Increases the hit area of the button on mobile. 'after:absolute after:-inset-2 md:after:hidden', 'peer-data-[size=sm]/menu-button:top-1', 'peer-data-[size=default]/menu-button:top-1.5', 'peer-data-[size=lg]/menu-button:top-2.5', 'group-data-[collapsible=icon]:hidden', showOnHover && 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0', className, )} {...props} /> ); }
TypeScript
๋ณต์‚ฌ
SidebarMenuAction์€ SidebarMenuButton์˜ ์šฐ์ธก ๋์— ๋ถ™๋Š” ์ž‘์€ ์•ก์…˜ ๋ฒ„ํŠผ์ด๋‹ค. MenuButton๊ณผ peer๋กœ ๊ฐ•ํ•˜๊ฒŒ ์—ฐ๋™ ๋˜๋ฉฐ hover์‹œ ์ƒ‰์ƒ ๋™๊ธฐํ™”์™€ size์— ๋”ฐ๋ฅธ ์œ„์น˜ ์ž๋™ ์กฐ์ • ๋“ฑ์˜ ๊ธฐ๋Šฅ์„ ์—ฐ๊ฒฐํ•œ๋‹ค. menu-item๊ณผ์˜ ์ƒํƒœ์™€๋„ ์—ฐ๋™ ๋˜๋ฉฐ ๋ฉ”๋‰ด ์•„์ดํ…œ๊ณผ ์ผ์ฒด๊ฐ ์žˆ๊ฒŒ ๋™์ž‘ํ•˜๋Š” ์•ก์…˜ ๋ฒ„ํŠผ์ด๋‹ค.
SidebarMenuBadge
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot='sidebar-menu-badge' data-sidebar='menu-badge' className={cn( 'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none', 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', 'peer-data-[size=sm]/menu-button:top-1', 'peer-data-[size=default]/menu-button:top-1.5', 'peer-data-[size=lg]/menu-button:top-2.5', 'group-data-[collapsible=icon]:hidden', className, )} {...props} /> ); }
TypeScript
๋ณต์‚ฌ
SidebarMenuAction๊ณผ ๋น„์Šทํ•˜๊ฒŒ ๋ฉ”๋‰ด์˜ ์šฐ์ธก์— ๋‚˜ํƒ€๋‚˜๋Š” ์ปดํฌ๋„ŒํŠธ์ด๋‹ค. ํ•˜์ง€๋งŒ, ํฌ์ธํ„ฐ ์ด๋ฒคํŠธ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๊ณ  ์ˆซ์ž๊ฐ™์€ ๋‹จ์ˆœํžˆ ๋ฐ์ดํ„ฐ๋ฅผ ํ‘œ์‹œํ•˜๋Š” ์šฉ๋„๋กœ ์‚ฌ์šฉ ๋œ๋‹ค.
SidebarMenuSkeleton
function SidebarMenuSkeleton({ className, showIcon = false, ...props }: React.ComponentProps<'div'> & { showIcon?: boolean; }) { // Random width between 50 to 90%. const width = React.useMemo(() => { return `${Math.floor(Math.random() * 40) + 50}%`; }, []); return ( <div data-slot='sidebar-menu-skeleton' data-sidebar='menu-skeleton' className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)} {...props} > {showIcon && ( <Skeleton className='size-4 rounded-md' data-sidebar='menu-skeleton-icon' /> )} <Skeleton className='h-4 max-w-(--skeleton-width) flex-1' data-sidebar='menu-skeleton-text' style={ { '--skeleton-width': width, } as React.CSSProperties } /> </div> ); }
TypeScript
๋ณต์‚ฌ
์‚ฌ์ด๋“œ๋ฐ”์—์„œ ๋กœ๋”ฉ๊ณผ ๊ฐ™์€ ์ปจํ…์ŠคํŠธ ๋ฐœ์ƒ ์‹œ ๋ณด์—ฌ์ฃผ๋Š” ์ปดํฌ๋„ŒํŠธ์ด๋‹ค. width๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์ปจํ…์ธ ์˜ ๊ธธ์ด๋ฅผ ๋žœ๋ค ๊ธธ์ด๋กœ ๋ณด์—ฌ์ค€๋‹ค. ์™œ๋ƒํ•˜๋ฉด ์ปจํ…์ธ ๊ฐ€ ๋ชจ๋‘ ๊ฐ™์€ ๊ธธ์ด๊ฐ€ ์•„๋‹ˆ๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. showIcon์„ true๋กœ ์„ค์ •ํ•˜๋ฉด ์ขŒ์ธก์— ๋™๊ทธ๋ž€ ์Šค์ผˆ๋ ˆํ†ค์ด ์ถ”๊ฐ€ ๋œ๋‹ค.
SidebarMenuSub & SidebarMenuSubItem
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) { return ( <ul data-slot='sidebar-menu-sub' data-sidebar='menu-sub' className={cn( 'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5', 'group-data-[collapsible=icon]:hidden', className, )} {...props} /> ); } function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) { return ( <li data-slot='sidebar-menu-sub-item' data-sidebar='menu-sub-item' className={cn('group/menu-sub-item relative', className)} {...props} /> ); }
TypeScript
๋ณต์‚ฌ
Sidebar Submenu๋ฅผ ํ‘œ์‹œํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์ด๋‹ค. SidebarMenuSub๋Š” ์„œ๋ธŒ ๋ฉ”๋‰ด๋ฅผ ๊ฐ์‹ธ๋Š” ul ํƒœ๊ทธ์ด๊ณ , SidebarMenuSubItem์€ SidebarMenuSub ์•ˆ์— ๋ Œ๋”๋ง ๋˜๋Š” ์•„์ดํ…œ๋“ค์ด๋‹ค.
SidebarMenuSubButton
function SidebarMenuSubButton({ asChild = false, size = 'md', isActive = false, className, ...props }: React.ComponentProps<'a'> & { asChild?: boolean; size?: 'sm' | 'md'; isActive?: boolean; }) { const Comp = asChild ? Slot : 'a'; return ( <Comp data-slot='sidebar-menu-sub-button' data-sidebar='menu-sub-button' data-size={size} data-active={isActive} className={cn( 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', size === 'sm' && 'text-xs', size === 'md' && 'text-sm', 'group-data-[collapsible=icon]:hidden', className, )} {...props} /> ); }
TypeScript
๋ณต์‚ฌ
SidebarMenuButton๊ณผ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ SidebarMenuSubItem ์•ˆ์— ๋ Œ๋”๋ง ๋˜๋Š” ์ปจํ…์ธ ์ด๋‹ค.asChild ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ true์ด๋ฉด child์— ์Šคํƒ€์ผ๊ณผ ๋™์ž‘๋งŒ์„ ์ œ๊ณตํ•  ์ˆ˜ ์žˆ๋‹ค.