Sheet
从屏幕边缘滑入的面板:筛选、导航、手机侧栏。缝线头、一块滚动的正文、永远够得着的脚。
sheet-demo.tsx
"use client"
import { CheckIcon, SlidersHorizontalIcon } from "lucide-react"
import { useState } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
const FACETS = [
{ name: "分辨率", options: ["2160p", "1080p", "720p"] },
{ name: "字幕语言", options: ["简中", "繁中", "简繁", "日语"] },
{ name: "字幕类型", options: ["内封", "内嵌", "外挂"] },
{ name: "文件格式", options: ["MKV", "MP4"] },
]
export default function SheetDemo() {
const [chosen, setChosen] = useState<Record<string, string[]>>({ 分辨率: ["1080p"], 字幕语言: ["简中", "简繁"] })
const count = Object.values(chosen).reduce((sum, list) => sum + list.length, 0)
const toggle = (facet: string, option: string) =>
setChosen((current) => {
const list = current[facet] ?? []
return { ...current, [facet]: list.includes(option) ? list.filter((item) => item !== option) : [...list, option] }
})
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="secondary">
<SlidersHorizontalIcon />
筛选
{count ? <Badge variant="count">{count}</Badge> : null}
</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>筛选资源</SheetTitle>
<SheetDescription>只看符合条件的发布,条件会记在这台设备上。</SheetDescription>
</SheetHeader>
<SheetBody className="flex flex-col gap-5">
{FACETS.map((facet) => (
<fieldset key={facet.name} className="flex flex-col gap-2">
<legend className="anibt-label">{facet.name}</legend>
<div className="flex flex-wrap gap-1.5">
{facet.options.map((option) => {
const on = chosen[facet.name]?.includes(option) ?? false
return (
<button
key={option}
type="button"
aria-pressed={on}
onClick={() => toggle(facet.name, option)}
className={`anibt-chip focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${on ? "anibt-chip-on pl-2" : ""}`}
>
{on ? <CheckIcon className="size-3" strokeWidth={3} aria-hidden="true" /> : null}
{option}
</button>
)
})}
</div>
</fieldset>
))}
</SheetBody>
<SheetFooter>
<Button variant="ghost" onClick={() => setChosen({})}>
重置
</Button>
<SheetClose asChild>
<Button>{count ? "看筛选后的资源" : "看全部资源"}</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)
}安装
npx shadcn@latest add @anibt/sheet第一次用之前:在 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 lucide-react radix-ui - 3. 把下面的文件放进项目components/ui/sheet.tsx
"use client" import * as React from "react" import { XIcon } from "lucide-react" import { Dialog as SheetPrimitive } from "radix-ui" import { cn } from "@/lib/utils" /** * Sheet — AniBT 「贴纸手账」: the panel that slides in from an edge (filters, * navigation, a phone's sidebar). * * The same floating paper, scrim and close square as Dialog. Only the corners * away from the edge are rounded (14). `SheetHeader` is the stitched bar, * `SheetBody` is the one region that scrolls, and `SheetFooter` sits at the * bottom over a stitched top edge, so its buttons are always reachable. * * Export names and props are shadcn's (`side`, `showCloseButton`), so this * file can replace `components/ui/sheet.tsx` — shadcn's sidebar opens its * phone layout in it. */ 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 Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { return <SheetPrimitive.Root data-slot="sheet" {...props} /> } function SheetTrigger(props: React.ComponentProps<typeof SheetPrimitive.Trigger> & RenderCompat) { return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...renderAsChild(props)} /> } function SheetClose(props: React.ComponentProps<typeof SheetPrimitive.Close> & RenderCompat) { return <SheetPrimitive.Close data-slot="sheet-close" {...renderAsChild(props)} /> } function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) { return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> } function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) { return ( <SheetPrimitive.Overlay data-slot="sheet-overlay" className={cn( "fixed inset-0 z-50 bg-anibt-scrim duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", className )} {...props} /> ) } function SheetContent({ className, children, side = "right", showCloseButton = true, closeLabel = "关闭", ...props }: React.ComponentProps<typeof SheetPrimitive.Content> & { side?: "top" | "right" | "bottom" | "left" showCloseButton?: boolean /** The close button's accessible name. */ closeLabel?: string }) { return ( <SheetPortal> <SheetOverlay /> <SheetPrimitive.Content data-slot="sheet-content" data-side={side} className={cn( "anibt-pop fixed z-50 flex flex-col overflow-y-auto overscroll-contain text-[14px] outline-none data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:animate-in data-[state=open]:duration-300 data-[state=open]:ease-anibt-out", side === "right" && "inset-y-0 right-0 h-full w-3/4 rounded-r-none data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", side === "left" && "inset-y-0 left-0 h-full w-3/4 rounded-l-none data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", side === "top" && "inset-x-0 top-0 h-auto max-h-[80dvh] rounded-t-none data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", side === "bottom" && "inset-x-0 bottom-0 h-auto max-h-[80dvh] rounded-b-none pb-[env(safe-area-inset-bottom,0px)] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", className )} {...props} > {children} {showCloseButton ? ( <SheetPrimitive.Close data-slot="sheet-close" className="anibt-icobtn anibt-icobtn-ghost absolute top-3 right-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid disabled:pointer-events-none" > <XIcon className="size-4" /> <span className="sr-only">{closeLabel}</span> </SheetPrimitive.Close> ) : null} </SheetPrimitive.Content> </SheetPortal> ) } /** The 56px stitched bar at the top; its end is kept clear of the close square. */ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-header" className={cn( "anibt-stitch-under flex min-h-14 shrink-0 flex-col justify-center gap-1 px-4 py-3 pe-14 text-left", className )} {...props} /> ) } /** The one scrolling region of a sheet. */ function SheetBody({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-body" className={cn("min-h-0 flex-1 overflow-y-auto overscroll-contain p-4", className)} {...props} /> ) } /** Buttons at the bottom over a stitched top edge. */ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-footer" className={cn("anibt-stitch-over mt-auto flex shrink-0 flex-col gap-2 p-4 sm:flex-row sm:justify-end", className)} {...props} /> ) } /** 16/800 ink. */ function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) { return ( <SheetPrimitive.Title data-slot="sheet-title" className={cn("text-[16px] leading-snug font-extrabold text-foreground", className)} {...props} /> ) } /** 12, muted. */ function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) { return ( <SheetPrimitive.Description data-slot="sheet-description" className={cn("text-[12px] leading-normal text-muted-foreground", className)} {...props} /> ) } export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetBody, SheetHeader, SheetFooter, SheetTitle, SheetDescription }
用法
components/filter-sheet.tsx
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
<Sheet>
<SheetTrigger asChild>
<Button variant="secondary">筛选</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>筛选资源</SheetTitle>
<SheetDescription>只看符合条件的发布。</SheetDescription>
</SheetHeader>
<SheetBody>{/* 条件 */}</SheetBody>
<SheetFooter>
<SheetClose asChild>
<Button>应用</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>结构
- 面板和弹窗同一套浮层、遮罩和关闭方块,只把远离边缘的角做圆。
SheetHeader是 56 高的缝线头:标题 16/800,说明 12 号弱色,右端给关闭按钮让位。SheetBody是唯一滚动的区域;SheetFooter贴底,上沿是缝线,按钮在手机上竖排、宽屏右对齐。- 左右两侧宽 3/4,最宽 384;上下两侧最高 80% 视口,底部面板留出 Home 指示条的安全区。
方向
sheet-side.tsx
"use client"
import { ArrowDownIcon, ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetBody,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
const SIDES = [
{ side: "top", label: "上", Icon: ArrowDownIcon },
{ side: "right", label: "右", Icon: ArrowLeftIcon },
{ side: "bottom", label: "下", Icon: ArrowUpIcon },
{ side: "left", label: "左", Icon: ArrowRightIcon },
] as const
export default function SheetSide() {
return (
<div className="grid grid-cols-2 gap-2 sm:flex">
{SIDES.map(({ side, label, Icon }) => (
<Sheet key={side}>
<SheetTrigger asChild>
<Button variant="secondary">
<Icon />
从{label}边滑入
</Button>
</SheetTrigger>
<SheetContent side={side}>
<SheetHeader>
<SheetTitle>今日放送</SheetTitle>
<SheetDescription>side="{side}"</SheetDescription>
</SheetHeader>
<SheetBody className="text-anibt-ink2">
2026 年 10 月 6 日 周二 · 9 部番剧更新。贴在{label}边的面板只把远离边缘的两个角做圆。
</SheetBody>
</SheetContent>
</Sheet>
))}
</div>
)
}属性
SheetContent
side"top" | "right" | "bottom" | "left"默认 "right"从哪条边滑入
showCloseButtonboolean默认 true右上角的安静关闭方块
closeLabelstring默认 "关闭"关闭按钮的读屏名称
导出名与 shadcn 一致,另加 SheetBody。shadcn 的 sidebar 在手机上用它打开侧栏,替换后照常工作;SheetTrigger 和 SheetClose 也接受 Base UI 的 render。
