Drawer
手机上的「一条记录」容器:从底边拖出来的浮层,带拖动条,脚部避开 Home 指示条。
drawer-demo.tsx
"use client"
import { DownloadIcon, MagnetIcon, SmartphoneIcon } from "lucide-react"
import { Badge, StatusBadge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerBody,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const FACTS = [
["字幕组", "喵萌奶茶屋"],
["大小", "1.2 GiB"],
["发布时间", "2026-10-06 21:30"],
["做种 / 下载", "128 / 9"],
]
export default function DrawerDemo() {
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="secondary">
<SmartphoneIcon />
打开资源抽屉
</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto flex w-full max-w-md min-h-0 flex-1 flex-col">
<DrawerHeader>
<DrawerTitle>药屋少女的呢喃 第三季 · 第 12 集</DrawerTitle>
<DrawerDescription>[喵萌奶茶屋] Kusuriya no Hitorigoto S3 - 12 [1080p][简繁内封]</DrawerDescription>
</DrawerHeader>
<DrawerBody className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-center gap-1.5 md:justify-start">
<Badge variant="res">1080p</Badge>
<Badge variant="res">HEVC</Badge>
<Badge variant="lang" face="text">
简繁内封
</Badge>
<StatusBadge tone="ok">做种健康</StatusBadge>
</div>
<dl className="anibt-well grid grid-cols-[auto_1fr] gap-x-4 px-3 py-1 text-[13px]">
{FACTS.map(([term, detail]) => (
<div key={term} className="anibt-stitch-under col-span-2 grid grid-cols-subgrid py-2 last:bg-none">
<dt className="text-muted-foreground">{term}</dt>
<dd className="anibt-num text-right text-foreground">{detail}</dd>
</div>
))}
</dl>
</DrawerBody>
<DrawerFooter>
<Button size="lg">
<MagnetIcon />
复制磁力链接
</Button>
<Button size="lg" variant="secondary">
<DownloadIcon />
下载种子
</Button>
<DrawerClose asChild>
<Button variant="ghost">关闭</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
}安装
npx shadcn@latest add @anibt/drawer第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。
手动安装
- 1. 先装主题组件只读
@anibt/theme的 token 和配方,没有主题就没有样子。npx shadcn@latest add @anibt/theme - 2. 安装依赖
npm install vaul - 3. 把下面的文件放进项目components/ui/drawer.tsx
"use client" import * as React from "react" import { Drawer as DrawerPrimitive } from "vaul" import { cn } from "@/lib/utils" /** * Drawer — AniBT 「贴纸手账」: the phone's container for "one record", dragged * up from the bottom edge (vaul). * * The same floating paper and scrim as Dialog, rounded only on the corners * away from its edge. A bottom drawer carries a grab handle and clears the * home-indicator inset, so its footer stays tappable. `DrawerHeader` is * parted from the content by the dashed stitch, `DrawerBody` is the one * region that scrolls, and `DrawerFooter` stacks full-width buttons. * * Export names and props are shadcn's, so this file can replace * `components/ui/drawer.tsx`. */ type RenderCompat = { /** Base UI's `asChild`: render this element instead, with the part's props. */ render?: React.ReactElement /** Base UI compatibility; ignored (Radix decides from the rendered element). */ nativeButton?: boolean } /** Maps Base UI's `render` onto Radix's `asChild`. */ function renderAsChild<P extends { asChild?: boolean; children?: React.ReactNode }>({ render, nativeButton: _nativeButton, ...props }: P & RenderCompat): P { void _nativeButton if (!render) return props as P const children = props.children === undefined ? render : React.cloneElement(render, undefined, props.children) return { ...props, asChild: true, children } as P } function Drawer({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) { return <DrawerPrimitive.Root data-slot="drawer" {...props} /> } function DrawerTrigger(props: React.ComponentProps<typeof DrawerPrimitive.Trigger> & RenderCompat) { return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...renderAsChild(props)} /> } function DrawerPortal({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Portal>) { return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} /> } function DrawerClose(props: React.ComponentProps<typeof DrawerPrimitive.Close> & RenderCompat) { return <DrawerPrimitive.Close data-slot="drawer-close" {...renderAsChild(props)} /> } function DrawerOverlay({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Overlay>) { return ( <DrawerPrimitive.Overlay data-slot="drawer-overlay" className={cn( "fixed inset-0 z-50 bg-anibt-scrim data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", className )} {...props} /> ) } /** The grab handle on a bottom drawer: a 36×4 pill in the control-edge ink. */ function DrawerSwipeHandle({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="drawer-swipe-handle" aria-hidden="true" className={cn("mx-auto mt-2.5 mb-0.5 h-1 w-9 shrink-0 rounded-full bg-input", className)} {...props} /> ) } function DrawerContent({ className, children, ...props }: React.ComponentProps<typeof DrawerPrimitive.Content>) { return ( <DrawerPortal data-slot="drawer-portal"> <DrawerOverlay /> <DrawerPrimitive.Content data-slot="drawer-content" className={cn( "group/drawer-content anibt-pop fixed z-50 flex h-auto flex-col overscroll-contain text-[14px] outline-none", "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[82dvh] data-[vaul-drawer-direction=top]:rounded-t-none", "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[82dvh] data-[vaul-drawer-direction=bottom]:rounded-b-none data-[vaul-drawer-direction=bottom]:pb-[env(safe-area-inset-bottom,0px)]", "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-r-none data-[vaul-drawer-direction=right]:sm:max-w-sm", "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-l-none data-[vaul-drawer-direction=left]:sm:max-w-sm", className )} {...props} > <DrawerSwipeHandle className="hidden group-data-[vaul-drawer-direction=bottom]/drawer-content:block" /> {children} </DrawerPrimitive.Content> </DrawerPortal> ) } /** Title and description over the stitch; centred on a bottom or top drawer. */ function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="drawer-header" className={cn( "anibt-stitch-under flex shrink-0 flex-col gap-1 px-4 pt-3 pb-3 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:text-left md:group-data-[vaul-drawer-direction=bottom]/drawer-content:text-left", className )} {...props} /> ) } /** The one scrolling region of a drawer; the footer stays reachable. */ function DrawerBody({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="drawer-body" className={cn("min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3", className)} {...props} /> ) } /** Full-width buttons, stacked, the action first. */ function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="drawer-footer" className={cn("mt-auto flex shrink-0 flex-col gap-2 p-4 pt-2", className)} {...props} /> ) } /** 16/800 ink. */ function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) { return ( <DrawerPrimitive.Title data-slot="drawer-title" className={cn("text-[16px] leading-snug font-extrabold text-foreground", className)} {...props} /> ) } /** 12, muted. */ function DrawerDescription({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Description>) { return ( <DrawerPrimitive.Description data-slot="drawer-description" className={cn("text-[12px] leading-normal text-muted-foreground", className)} {...props} /> ) } export { Drawer, DrawerPortal, DrawerOverlay, DrawerSwipeHandle, DrawerTrigger, DrawerClose, DrawerContent, DrawerBody, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, }
用法
components/release-drawer.tsx
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerBody,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
<Drawer>
<DrawerTrigger asChild>
<Button variant="secondary">资源详情</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>药屋少女的呢喃 第三季 · 第 12 集</DrawerTitle>
<DrawerDescription>[喵萌奶茶屋] … [1080p][简繁内封]</DrawerDescription>
</DrawerHeader>
<DrawerBody>{/* 详情 */}</DrawerBody>
<DrawerFooter>
<Button size="lg">复制磁力链接</Button>
<DrawerClose asChild>
<Button variant="ghost">关闭</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>结构
- 基于 vaul:可以拖动关闭,
direction决定从哪条边出来(默认底边)。 - 底部抽屉顶上有 36×4 的拖动条(
DrawerSwipeHandle),最高 82% 视口,只有上面两个角是圆的。 DrawerHeader在手机上居中、宽屏左对齐,下沿是缝线;DrawerBody是唯一滚动的区域;DrawerFooter竖排整宽按钮,主操作在最上面。- 宽屏上抽屉也会铺满宽度,内容可以像例子那样放进
max-w-md的容器里居中。
什么时候用
桌面上「一条记录」用 Dialog,手机上用 Drawer。两者的头、正文、脚是同一套结构,可以按屏幕宽度二选一。
属性
Drawer
direction"top" | "bottom" | "left" | "right"默认 "bottom"从哪条边出来(vaul)
shouldScaleBackgroundboolean默认 false打开时把页面缩小一点(vaul)
dismissibleboolean默认 true能否拖动、点遮罩或 Esc 关闭(vaul)
导出名与 shadcn 一致,另加 DrawerBody 和 DrawerSwipeHandle。
