ByteNoteByteNote
桌面给侧栏,手机给抽屉:双形态布局实战
字

字节笔记本

2026年10月7日 · 约 10 分钟读完

桌面给侧栏,手机给抽屉:双形态布局实战

API中转
¥120

导航是每个前端项目里第一个被写出来的组件。内容型站点通常一条顶部横栏加一个汉堡菜单就能交差,但做后台、编辑器、工作台这类"应用壳"时,横栏会迅速不够用:入口多、层级深、右侧还常挂一块常驻信息栏。这时布局要换一副骨架——桌面端一根固定侧边栏,移动端收成一张抽屉。本文以一次真实的三轮迭代为底本,用 React、Tailwind CSS 与 Headless UI 把这套双形态布局完整拆开。

一、先判断:横栏还是侧栏

这次迭代的起点是一个标准的顶部导航栏:深色横条,左侧 Logo,右侧四个链接,窄屏收进汉堡菜单。这版实现放在内容站上毫无问题,但当真实的页面结构——带侧栏的应用骨架——摆进来之后,横栏就露了怯:顶部再挂一条导航,页面瞬间有了两层"机关",而真正的功能入口全都挤在左边一条竖向空间里。形态必须跟着信息架构走,这正是两种形态的分野。

顶部导航适合浅层结构:链接三五个、彼此平级,横向排开一眼扫完;侧边栏适合纵向生长的应用:菜单会分组、会滚动、会随业务持续加条目,纵向列表几乎不设上限。判断方法很简单——如果导航项多到要在横栏里塞"更多"下拉,或者每个页面都需要一块常驻的上下文信息(目录、筛选器、元信息),就该把骨架换成侧栏。

迭代终点因此是一个标准的三段式应用壳:左侧固定侧栏、中间主内容区、右侧只在宽屏出现的次要栏。桌面骨架用 Tailwind 写出来只有几行:

jsx
{/* 桌面端固定侧栏 */}
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-64 lg:flex-col">
  <div className="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4">
    <Logo />
    <Navigation />
  </div>
</div>

{/* 主区给侧栏让位 */}
<main className="lg:pl-64 bg-white min-h-screen">{children}</main>

{/* 右栏只在 xl 出现 */}
<aside className="fixed inset-y-0 right-0 hidden w-80 xl:block">{/* 次要栏 */}</aside>

hidden 是移动优先的底色:侧栏在窄屏默认不可见,从 lg(1024px)起转为固定定位、贯通上下、宽 16rem。主区用 lg:pl-64 补上同宽的左内边距给它让位——这两个数值必须成对出现,后文会讲不配对时会发生什么。右栏则推迟到 xl(1280px)才出现,毕竟三栏同屏是宽屏特权。

断点与布局形态映射

二、移动端:条件渲染撑不起一张抽屉

窄屏下侧栏退场,导航不能跟着消失。最朴素的写法是一个 useState 加条件渲染,点开就瞬间出现——汉堡菜单最常见的第一版。它能用,但有三处先天不足:没有遮罩,菜单打开后背后的页面还在眼前晃;没有过渡,菜单凭空闪现,方向感全无;没有模态语义,焦点仍在背后,按 Esc 也无人响应。

Headless UI 的 Dialog 与 Transition 正为补这三件事而来。抽屉打开时的结构分三层:

jsx
<Transition.Root show={sidebarOpen} as={Fragment}>
  <Dialog as="div" className="relative z-50 lg:hidden" onClose={setSidebarOpen}>
    {/* 第一层:遮罩,透明度过渡 */}
    <Transition.Child as={Fragment}
      enter="transition-opacity ease-linear duration-300"
      enterFrom="opacity-0" enterTo="opacity-100">
      <div className="fixed inset-0 bg-gray-900/80" />
    </Transition.Child>

    {/* 第二层:面板,位移滑入 */}
    <Transition.Child as={Fragment}
      enter="transition ease-in-out duration-300 transform"
      enterFrom="-translate-x-full" enterTo="translate-x-0">
      <Dialog.Panel className="relative mr-16 flex w-full max-w-xs flex-1 bg-white">
        {/* 站点 Logo + 纵向导航 */}
      </Dialog.Panel>
    </Transition.Child>
  </Dialog>
</Transition.Root>

三个细节值得咀嚼。其一,遮罩与面板各挂一个 Transition.Child,独立配置曲线:遮罩用 linear 的透明度渐变,面板用 ease-in-out 的位移,300 毫秒内两层以不同节奏运动,观感立刻脱离幻灯片味。其二,滑入用 transform 而非宽度或 left,前者走合成层,动画期间不触发重排,这是移动端动画的基本功。其三,面板右侧留了 mr-16 的空隙,关闭按钮用 left-full 绝对定位悬在面板左外侧——用户不必把手伸到屏幕最左边去找那个 X。

另有两件事是 Dialog 白送的:打开时焦点移入抽屉、按 Esc 触发 onClose、背后页面滚动被锁住。换条件渲染这些都得手写,漏一件就是线上 bug。

状态放哪也有讲究。sidebarOpen 这一个布尔值活在 Layout 手里,再分发给两处:Header 的汉堡按钮拿它置真,Dialog 的 onClose 负责置假——点遮罩、按 Esc、点关闭按钮三条路最后都汇进同一个回调。开关状态只有一个所有者,就不存在"两个组件各自记着不一样的开合状态"这种经典竞态;将来若要在路由跳转后自动收起抽屉,也只需在 Layout 一处加一个副作用。

三、两副面孔,一套导航

抽屉和固定侧栏看起来是两个组件,导航本身却只有一份:一个 flex-col 排列的链接列表,同时塞进桌面侧栏和移动抽屉。这正是"一套 DOM、两种呈现"从断点级别上升到布局级别——变的是容器(fixed 侧栏还是 Dialog 抽屉),不变的是内容,导航项增删只改一处,两副面孔自动同步。

移动端还需要一根只属于它的顶栏:lg:hidden 的 sticky header,左边汉堡按钮负责把 sidebarOpen 置真,中间放站点名。注意它和侧栏互斥——lg 以下是"顶栏加抽屉",以上是"固定侧栏",同一时刻只有一套导航入口在场,用户永远不会面对两个都能打开菜单的按钮。抽屉宽度也有分寸:max-w-xs 把面板封顶在 20rem,即便在平板竖屏上也不会全屏铺开把内容整个盖住;再配上面板右缘那条 mr-16 空隙,底下页面始终露出一指宽,"这是一个可关闭的浮层"这件事不言自明。

断点为什么切在 lg 而不是更常见的 md?因为对象是应用壳,主区还要给右栏留 20rem 余地,1024px 以下再横也摆不平三栏,不如让侧栏整体退场换抽屉。内容型站点入口少、主区宽,切 md 甚至不切都行。断点跟着内容宽度走,不跟习惯走。

四、样式打磨与两个真实的坑

结构定了之后,第三轮迭代只动样式,却暴露出两个典型陷阱。

第一个坑是宽度不配对。初版侧栏写的是 lg:w-50,而 Tailwind 默认间距刻度里根本没有 50 这一档——这个类要么不生效,要么得靠任意值语法 w-[12.5rem] 救活;更麻烦的是主区让位用的却是 lg:pl-64,两边数值不一致,侧栏和正文之间会裂开一条谁也没认领的缝。修正方式是把两侧统一到同一刻度:侧栏 lg:w-64、主区 lg:pl-64,16rem 对 16rem,严丝合缝。凡是"容器定宽加内容让位"的组合,两个值都该来自同一个常量。

第二个坑是层次感要靠背景垫出来。初版侧栏、主区、右栏全是灰底灰边,糊成一片;打磨后反过来:三栏本体都用 bg-white,最外层垫一层 bg-gray-100,再用 border-r、border-l 勾出边界,白面板在浅灰画布上自然浮起。交互元素补上 hover:text-gray-900 一类的悬停过渡,按钮的 focus ring 保留——键盘用户的位置感不因打磨而丢失。z-index 也要立约:抽屉 z-50、顶栏 z-40,谁压谁一目了然。

还有一类不起眼但决定"质感"的参数是间距节奏。抽屉和桌面侧栏虽然渲染在不同容器里,内边距却用了同一组数值:纵向 gap-y-5 撑开导航项,横向 px-6 统一留白,Logo 区都是 h-16 的固定行高。用户从桌面缩到手机,抽屉看起来就是"那根侧栏收起来了",而不是换了一个组件——双形态布局最怕的就是两副面孔各长各样。

应用壳组件结构与状态流转

收尾

把三轮迭代串起来,这套双形态布局的要点其实是一张清单:桌面侧栏与主区让位必须同刻度对齐;移动抽屉交给 Dialog 拿回遮罩、动画与模态语义;两副面孔共享同一份导航组件;断点按内容宽度定,不按习惯定。再往远处看,Container Queries 正让组件学会按容器而非视口决定形态,将来"侧栏还是抽屉"或许能收进组件内部自动完成。在那之前,先把手里的断点和刻度对齐,仍是响应式布局最实惠的功夫。

相关文章

分享: