AniBTUI
✦ 组件@anibt/drawerregistry JSON

Drawer

手机上的「一条记录」容器:从底边拖出来的浮层,带拖动条,脚部避开 Home 指示条。

安装

npx shadcn@latest add @anibt/drawer

第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。

手动安装
  1. 1. 先装主题组件只读 @anibt/theme 的 token 和配方,没有主题就没有样子。
    npx shadcn@latest add @anibt/theme
  2. 2. 安装依赖
    npm install vaul
  3. 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。