Bỏ qua đến nội dung

Ngăn kéo

Một giao diện điều khiển bằng cử chỉ, có thể trượt vào từ bất kỳ cạnh nào, dành cho các menu trên thiết bị di động, bộ lọc và biểu mẫu nhanh — với các đường viền và hiệu ứng bóng theo phong cách tân thô mộc.

"use client"

import * as React from "react"

Drawer là một panel trượt vào từ một cạnh của màn hình và có thể kéo để đóng lại. Được xây dựng trên Vaul của Emil Kowalski và tạo kiểu theo công thức neobrutalist: viền dày, bóng đổ đậm và kiểu chữ đậm.

Nên dùng khi:

  • Action sheet trên mobile — menu chia sẻ, tùy chọn sắp xếp, xác nhận nhanh. Cử chỉ kéo để đóng dễ chịu hơn nhiều so với việc nhắm vào một nút đóng bé xíu khi dùng cảm ứng.
  • Panel bộ lọc và cài đặt — bộ lọc thương mại điện tử hay các tùy chọn cần nhiều chỗ hơn một popover nhưng chưa đến mức phải có trang riêng.
  • Dialog đáp ứng — kết hợp với Dialog để hiện modal trên desktop và drawer trên mobile (xem ví dụ bên dưới).

Giới thiệu

Drawer được xây dựng dựa trên Vaul của emilkowalski.

Cài đặt

pnpm dlx shadcn@latest add https://neobrutalism.com/r/base/drawer.json
npx shadcn@latest add https://neobrutalism.com/r/base/drawer.json
yarn dlx shadcn@latest add https://neobrutalism.com/r/base/drawer.json
bunx --bun shadcn@latest add https://neobrutalism.com/r/base/drawer.json

Cài đặt các phụ thuộc sau:

pnpm add vaul
npm install vaul
yarn add vaul
bun add vaul

Sao chép và dán đoạn mã sau vào dự án của bạn.

components/ui/drawer.tsx
"use client"

import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"

import { cn } from "@/lib/utils"

function Drawer({
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
  return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}

function DrawerTrigger({
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
  return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...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>) {
  return <DrawerPrimitive.Close data-slot="drawer-close" {...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-foreground/20 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
        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 fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground shadow-md 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-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t-2 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-r-xl data-[vaul-drawer-direction=left]:border-r-2 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-l-xl data-[vaul-drawer-direction=right]:border-l-2 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-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b-2 data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
          className
        )}
        {...props}
      >
        <div className="mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
        {children}
      </DrawerPrimitive.Content>
    </DrawerPortal>
  )
}

function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="drawer-header"
      className={cn(
        "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
        className
      )}
      {...props}
    />
  )
}

function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="drawer-footer"
      className={cn("mt-auto flex flex-col gap-2 p-4", className)}
      {...props}
    />
  )
}

function DrawerTitle({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
  return (
    <DrawerPrimitive.Title
      data-slot="drawer-title"
      className={cn(
        "font-head text-base font-medium text-foreground",
        className
      )}
      {...props}
    />
  )
}

function DrawerDescription({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
  return (
    <DrawerPrimitive.Description
      data-slot="drawer-description"
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  )
}

export {
  Drawer,
  DrawerPortal,
  DrawerOverlay,
  DrawerTrigger,
  DrawerClose,
  DrawerContent,
  DrawerHeader,
  DrawerFooter,
  DrawerTitle,
  DrawerDescription,
}

Cập nhật các đường dẫn import cho phù hợp với thiết lập dự án của bạn.

Cách dùng

import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
<Drawer>
  <DrawerTrigger>Open</DrawerTrigger>
  <DrawerContent>
    <DrawerHeader>
      <DrawerTitle>Are you absolutely sure?</DrawerTitle>
      <DrawerDescription>This action cannot be undone.</DrawerDescription>
    </DrawerHeader>
    <DrawerFooter>
      <Button>Submit</Button>
      <DrawerClose>
        <Button variant="outline">Cancel</Button>
      </DrawerClose>
    </DrawerFooter>
  </DrawerContent>
</Drawer>

Kết hợp

Dùng cách kết hợp sau để xây dựng một Drawer:

Drawer
├── DrawerTrigger
└── DrawerContent
    ├── DrawerHeader
    │   ├── DrawerTitle
    │   └── DrawerDescription
    └── DrawerFooter

Ví dụ

Nội dung có thể cuộn

Giữ các hành động hiển thị trong khi nội dung cuộn.

import { Button } from "@/components/ui/button"
import {
  Drawer,

Các cạnh

Dùng prop direction để thiết lập cạnh của drawer. Các tùy chọn khả dụng là top, right, bottomleft.

import { Button } from "@/components/ui/button"
import {
  Drawer,

Dialog đáp ứng

Bạn có thể kết hợp các thành phần DialogDrawer để tạo một dialog đáp ứng. Nó render một thành phần Dialog trên máy tính để bàn và một Drawer trên di động.

"use client"

import * as React from "react"

RTL

Để bật hỗ trợ RTL trong Neobrutalism, hãy xem hướng dẫn cấu hình RTL.

"use client"

import * as React from "react"

Khả năng truy cập

Drawer tuân theo mẫu Dialog (Modal) của WAI-ARIA: Vaul được xây trên primitive Radix Dialog, nên panel render với role="dialog" cùng aria-modal, giữ focus bên trong khi đang mở, được gắn nhãn bởi DrawerTitle và mô tả bởi DrawerDescription. Cử chỉ kéo để đóng chỉ dành cho con trỏ — người dùng bàn phím đóng bằng Escape hoặc bằng một nút DrawerClose, nên hãy luôn để sẵn một nút như vậy.

Tương tác bàn phím:

PhímHành động
Space / EnterMở drawer từ trigger đang được focus
Tab / Shift + TabLuân chuyển focus qua nội dung của drawer (focus bị giữ lại)
EscapeĐóng drawer và trả focus về trigger

Tham chiếu API

Xem tài liệu Vaul để biết tham chiếu API đầy đủ.