블록
모바일 내비 시트
좁은 화면에서 헤더 메뉴를 여는 서랍이에요. 링크, CTA, 설정 줄, 계정이 한 덩어리로 들어가요.
- #arc
- #block
- #navigation
- #drawer
- #mobile
- #menu
- #header
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/mobile-nav-sheet.json파일
components/arc/blocks/mobile-nav-sheet/mobile-nav-sheet.module.csscomponents/arc/blocks/mobile-nav-sheet/mobile-nav-sheet.tsx
npm 패키지
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10
mobile-nav-sheet.module.cssmobile-nav-sheet.module.cssmobile-nav-sheet.module.csscss
/* The drawer supplies the panel, its shadow and its matching border; the sheet only lays the contents out. */
.sheet { display: flex; min-height: 100%; flex-direction: column; gap: var(--space-5); }
.trigger { display: inline-flex; width: var(--control-height-md); height: var(--control-height-md); align-items: center; justify-content: center; border: 0; border-radius: var(--radius-pill); background: var(--surface-muted); color: var(--foreground); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background var(--duration-fast) var(--ease-standard); }
.nav { margin: 0; }
.list { display: flex; margin: 0; padding: 0; flex-direction: column; gap: var(--space-1); list-style: none; }
/* Touch targets are the full width of the sheet and comfortably past 44px, since this is the thumb's menu. */
.link { display: flex; min-height: var(--control-height-lg); align-items: center; gap: var(--space-3); border-radius: var(--radius-control); padding: 0 var(--space-4); color: var(--foreground); font-size: var(--label-lg); font-weight: var(--label-weight); line-height: var(--leading-body); text-decoration: none; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard); }
.linkLabel { flex: 1 1 auto; min-width: 0; }
.chevron { flex: none; color: var(--text-secondary); }
.link[aria-current="page"] { background: var(--accent-subtle); color: var(--accent); }
.link[aria-current="page"] .chevron { color: var(--accent); }
.cta { display: flex; min-height: var(--control-height-lg); align-items: center; justify-content: center; border: 1px solid var(--accent); border-radius: var(--radius-control); background: var(--accent); color: var(--accent-foreground); font-size: var(--label-lg); font-weight: var(--label-weight); line-height: var(--leading-body); text-decoration: none; transition: background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); }
.settings { display: flex; flex-direction: column; gap: var(--space-3); border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); }
.settingRow { display: flex; align-items: center; gap: var(--space-3); }
.settingLabel { flex: none; width: calc(var(--space-16) + var(--space-10)); color: var(--text-secondary); font-size: var(--label-md); }
.settingControl { display: flex; min-width: 0; flex: 1 1 auto; justify-content: flex-end; }
.account { margin-top: auto; display: flex; align-items: center; border-radius: var(--radius-control); padding: var(--space-2) var(--space-3); background: var(--surface-muted); }
@media (hover: hover) and (pointer: fine) {
.trigger:hover { background: var(--surface-sunken); }
.link:hover { background: var(--surface-muted); }
.link[aria-current="page"]:hover { background: var(--accent-subtle); }
.cta:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
}
.trigger:active { background: var(--surface-sunken); }
.link:active { background: var(--surface-sunken); }
.cta:active { background: var(--accent-strong); border-color: var(--accent-strong); }
@media (prefers-reduced-motion: reduce) { .trigger, .link, .cta { transition: none; } }
mobile-nav-sheet.tsxmobile-nav-sheet.tsxmobile-nav-sheet.tsxtsx
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Menu01Icon } from "@hugeicons/core-free-icons";
import { Drawer, DrawerContent, DrawerTrigger } from "../../drawer/drawer";
import { playUiSound } from "../../sound/play";
import { UiIcon } from "../../theme/ui-icon";
import styles from "./mobile-nav-sheet.module.css";
export interface MobileNavLink {
href: string;
label: string;
/** Marks the page the reader is on. */
current?: boolean;
}
export interface MobileNavSettingRow {
label: string;
control: ReactNode;
}
export interface MobileNavSheetProps {
/** The sheet's accessible title, e.g. "메뉴". */
title: string;
links: MobileNavLink[];
/** The accessible name of the list of links, e.g. "주요 메뉴". */
navLabel: string;
cta?: { href: string; label: string };
/** One row per setting, such as language and theme. */
settings?: MobileNavSettingRow[];
/** The account block, pinned to the bottom of the sheet. */
account?: ReactNode;
/** The accessible name of the default menu button, e.g. "메뉴 열기". */
triggerLabel?: string;
/** Replaces the default menu button. Needs to accept a ref and the Radix trigger props. */
trigger?: ReactNode;
side?: "left" | "right";
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
/**
* The header's menu on a narrow screen: the same links, the same call to action, and the settings and account that
* do not fit in the bar. It is a drawer, so focus is trapped while it is open, Escape closes it and focus returns to
* the button. Choosing a link closes it on the press rather than on the route change, so the panel never lingers
* over the next page.
*/
export function MobileNavSheet({
title,
links,
navLabel,
cta,
settings,
account,
triggerLabel = "메뉴 열기",
trigger,
side = "right",
open: openProp,
onOpenChange,
}: MobileNavSheetProps) {
const [openState, setOpenState] = useState(false);
const open = openProp ?? openState;
const setOpen = (next: boolean) => { setOpenState(next); onOpenChange?.(next); };
const close = () => setOpen(false);
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
{trigger ?? (
<button type="button" className={styles.trigger} aria-label={triggerLabel}>
<HugeiconsIcon icon={Menu01Icon} size={20} strokeWidth={1.75} aria-hidden="true" />
</button>
)}
</DrawerTrigger>
<DrawerContent title={title} side={side}>
<div className={styles.sheet}>
<nav aria-label={navLabel} className={styles.nav}>
<ul className={styles.list}>
{links.map(link => (
<li key={link.href}>
<a
href={link.href}
aria-current={link.current ? "page" : undefined}
className={styles.link}
onClick={() => { playUiSound("tick"); close(); }}
>
<span className={styles.linkLabel}>{link.label}</span>
<UiIcon iconRole="chevron-right" size={16} strokeWidth={1.75} className={styles.chevron} aria-hidden="true" />
</a>
</li>
))}
</ul>
</nav>
{cta ? (
<a href={cta.href} className={styles.cta} onClick={() => { playUiSound("click"); close(); }}>
{cta.label}
</a>
) : null}
{settings?.length ? (
<div className={styles.settings}>
{settings.map(row => (
<div key={row.label} className={styles.settingRow}>
<span className={styles.settingLabel}>{row.label}</span>
<div className={styles.settingControl}>{row.control}</div>
</div>
))}
</div>
) : null}
{account ? <div className={styles.account}>{account}</div> : null}
</div>
</DrawerContent>
</Drawer>
);
}
MobileNavSheet.displayName = "MobileNavSheet";
export default MobileNavSheet;
사용된 항목
- 서랍컴포넌트
화면 가장자리에서 밀려 나오는 패널이에요.