Yugen UI

Overlay thích ứng desktop & mobile

Cùng một tác vụ, khác thiết bị: chọn đúng Dialog, Drawer, Sheet hay Popover, và để Sheet tự thành panel trên màn hình rộng, bottom sheet trong tầm ngón cái trên điện thoại.

Chọn overlay

Bắt đầu từ tác vụ, rồi mới tới thiết bị. Overlay càng chặn nhiều thì càng phải xứng đáng với sự gián đoạn đó.

OverlayDesktopĐiện thoạiDùng khi
DialogHộp giữa màn hình, modal.Vẫn giữa màn hình; chỉ dùng cho nội dung ngắn.Quyết định ngay: xác nhận, xoá, nhập 1–3 trường. modalType="alert" cho hành động phá huỷ.
DrawerPanel cạnh trái / phải (overlay) hoặc nằm trong bố cục (inline).Dễ chiếm hết màn hình; nút đóng ở góc trên khó với tới.Điều hướng, panel phụ luôn ở một phía, đặc biệt InlineDrawer trên màn hình rộng.
Sheet (Labs)Panel cạnh start / end như Drawer.Bottom sheet kéo được, nhiều nấc, vuốt xuống để đóng.Tác vụ phụ cần không gian vừa phải trên mọi thiết bị: bộ lọc, chi tiết bản ghi, chia sẻ, chọn tuỳ chọn.
PopoverBề mặt nhỏ neo vào nút, không modal.Neo vào nút nhỏ, dễ bị bàn phím ảo hoặc mép màn hình che.Thông tin / lựa chọn ngắn gắn với một điểm cụ thể. Trên điện thoại cân nhắc chuyển sang Sheet.
  • Cần người dùng dừng lại và quyết định → Dialog. Không đặt quyết định quan trọng trong một sheet có thể vuốt mất.
  • Nội dung phụ trợ cho trang (lọc, xem chi tiết) và người dùng có thể dùng trên điện thoại → Sheet: một khai báo, hai dạng.
  • Lựa chọn nhỏ gắn với một nút → Popover trên desktop; trên màn hình hẹp đổi sang bottom sheet bằng useMediaQuery.
  • Điều hướng chính của ứng dụng → Nav / InlineDrawer, không phải overlay tạm thời.
import { useMediaQuery, Popover, PopoverTrigger, PopoverSurface, Sheet, SheetTrigger, SheetContent } from "@/components/ui";

// Menu chia sẻ: Popover neo vào nút trên desktop, bottom sheet trong tầm ngón cái trên điện thoại.
function ShareAction() {
  const isDesktop = useMediaQuery("(min-width: 768px)");
  const trigger = <Button icon={<Share20Regular />}>Chia sẻ</Button>;

  if (isDesktop) {
    return (
      <Popover>
        <PopoverTrigger asChild>{trigger}</PopoverTrigger>
        <PopoverSurface><ShareOptions /></PopoverSurface>
      </Popover>
    );
  }
  return (
    <Sheet mode="bottom" snapPoints={[0.4]}>
      <SheetTrigger asChild>{trigger}</SheetTrigger>
      <SheetContent>
        <SheetHeader><SheetTitle>Chia sẻ</SheetTitle></SheetHeader>
        <SheetBody><ShareOptions /></SheetBody>
      </SheetContent>
    </Sheet>
  );
}

Sheet: một API, hai dạng

Sheet chọn dạng theo chiều rộng viewport (useMediaQuery) — nội dung, focus và trạng thái giữ nguyên khi người dùng xoay máy hay thu nhỏ cửa sổ.

Viewport hiện < 768px → bottom sheet. Thu hẹp cửa sổ để đổi dạng.
<Sheet
  side="end"              // desktop: panel ở cạnh cuối dòng (trái khi RTL)
  size="medium"           // 592px
  breakpoint="3xl"        // < 48rem (768px) → bottom sheet
  snapPoints={[0.45, 0.9]}
  onSnapPointChange={(snap) => analytics.track("sheet_snap", { snap })}
>
  <SheetTrigger asChild><Button>Chi tiết đơn hàng</Button></SheetTrigger>
  <SheetContent>
    <SheetHeader action={<SheetClose asChild><Button appearance="subtle" icon={<Dismiss24Regular />} aria-label="Đóng" /></SheetClose>}>
      <SheetTitle>Đơn #2026-1020</SheetTitle>
      <SheetDescription>3 sản phẩm · giao trước 18:00</SheetDescription>
    </SheetHeader>
    <SheetBody>{/* nội dung dài — chỉ cuộn khi sheet ở nấc cao nhất */}</SheetBody>
    <SheetFooter>{/* luôn bám đáy màn hình, kể cả ở nấc 45% */}</SheetFooter>
  </SheetContent>
</Sheet>
Prop · KiểuMặc địnhMô tả
mode"auto" | "side" | "bottom"Mặc định: "auto"auto theo breakpoint; side / bottom ép một dạng.
breakpoint"lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | numberMặc định: "3xl"Chiều rộng viewport tối thiểu cho dạng panel: lg 32rem · xl 36rem · 2xl 42rem · 3xl 48rem · 4xl 56rem · 5xl 64rem. Số (px) đã lỗi thời.
side / size"start" | "end" / "small" | "medium" | "large"Mặc định: "end" / "small"Dạng panel: cạnh (tự đảo ở RTL) và độ rộng 320 / 592 / 940px.
snapPointsnumber[]Mặc định: [0.5, 0.9]Nấc chiều cao bottom sheet (phần của chiều cao viewport).
modal / dismissiblebooleanMặc định: true / trueLớp phủ + bẫy focus; cho phép Esc / bấm ngoài / vuốt để đóng.

Công thái học bottom sheet

Trên điện thoại cầm một tay, phần ba dưới màn hình là vùng ngón cái với tới dễ nhất — bottom sheet đưa nội dung và hành động vào đúng vùng đó.

Nấc gần nhất: 25%
Yếu tốHành vi trong YugenGợi ý
Vùng ngón cáiSheetFooter luôn bám đáy màn hình, kể cả ở nấc thấp.Đặt hành động chính ở footer, không ở tiêu đề.
Snap pointThả tay → về nấc gần nhất; nấc tính theo phần chiều cao viewport (dvh).2–3 nấc: xem nhanh (0.25–0.5), chi tiết (≈ 0.9 — chừa một dải trang phía trên để người dùng biết mình vẫn ở trang cũ).
Vuốt nhanhVận tốc > 0.45 px/ms → nhảy sang nấc kế theo hướng vuốt.Không cần kéo xa; một cú hất là đủ.
Vuốt xuống để đóngKéo thấp hơn nửa nấc thấp nhất hoặc hất xuống từ nấc thấp nhất; lớp phủ mờ dần theo tay.Tắt bằng dismissible={false} khi bắt buộc chọn.
Cuộn nội dungChỉ cuộn ở nấc cao nhất; ở nấc thấp, kéo ở đâu cũng di chuyển sheet. Đang ở đầu nội dung mà kéo xuống → kéo cả sheet.Nội dung dài nên mở ở nấc thấp trước rồi để người dùng kéo lên.
Vùng không kéoÔ nhập, textarea, select và phần tử có data-sheet-no-drag không khởi động kéo; vuốt ngang được nhường cho carousel / slider.Gắn data-sheet-no-drag cho bản đồ, canvas, vùng vẽ.

Trên trình duyệt desktop, thao tác kéo hoạt động với chuột (pointer events) khi Sheet ở dạng bottom; cuộn chuột và cử chỉ hai ngón trên touchpad không di chuyển sheet. Vì vậy dạng mặc định trên màn hình rộng là panel cạnh, không phải bottom sheet.

Bàn phím & trình đọc màn hình

Mọi cử chỉ đều có cách làm tương đương không cần kéo (WCAG 2.5.1 Pointer Gestures, 2.5.7 Dragging Movements).

Phím / tình huốngKết quả
EscĐóng Sheet (trừ khi dismissible={false}); focus trở về nút đã mở.
Tab / Shift+TabĐi vòng trong Sheet khi modal; không modal thì focus ra được trang phía sau.
Mở bottom sheetFocus vào chính bề mặt (role="dialog") để trình đọc màn hình đọc SheetTitle + SheetDescription.
Tay nắm (là một nút)Enter / Space: nấc kế tiếp (vòng lại); ↑ / ↓: lên / xuống một nấc. Nhãn đọc kèm nấc hiện tại, ví dụ “Đổi chiều cao (45%)”.
Focus bàn phím hoặc chạm ô nhập ở nấc thấpSheet tự mở lên nấc cao nhất để phần tử đang focus không nằm ngoài màn hình.
Vuốt để đóngLuôn kèm nút Đóng / Huỷ nhìn thấy được trong tiêu đề hoặc footer.

Sheet dựng trên Radix Dialog: aria-labelledby trỏ tới SheetTitle, aria-describedby chỉ được gắn khi có SheetDescription, phần còn lại của trang bị ẩn khỏi cây truy cập khi modal. Tay nắm chỉ là nút khi có từ hai nấc; với một nấc nó là trang trí (aria-hidden).

Vùng an toàn & bàn phím ảo

Tai thỏ, Dynamic Island và thanh home indicator lấn vào vùng hiển thị — bottom sheet phải chừa chỗ cho chúng.

  • Footer (và Body khi không có footer) cộng env(safe-area-inset-bottom) vào padding dưới để nút không nằm dưới thanh home.
  • Chiều cao tối đa = min(nấc cao nhất × 100dvh, 100dvh − env(safe-area-inset-top)) — không bao giờ chui dưới tai thỏ.
  • Dùng đơn vị dvh thay cho vh: chiều cao thật thay đổi khi thanh địa chỉ của trình duyệt di động co / giãn.
  • env() chỉ khác 0 khi trang khai báo viewport-fit=cover; thêm interactive-widget=resizes-content để bàn phím ảo đẩy nội dung lên thay vì che ô nhập.
<!-- index.html: bật env(safe-area-inset-*) và để bàn phím ảo co nội dung lại -->
<meta
  name="viewport"
  content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>

Giảm chuyển động

Khi hệ điều hành bật “giảm chuyển động”, overlay xuất hiện / biến mất tức thì — nhưng thao tác trực tiếp vẫn bám theo tay.

  • Trượt vào / ra, mờ lớp phủ và chuyển động về nấc đều tắt (motion-reduce:animate-none, motion-reduce:transition-none).
  • Kéo bằng tay vẫn di chuyển sheet theo ngón tay — đó là thao tác trực tiếp, không phải hoạt ảnh trang trí.
  • Hiệu ứng đàn hồi khi kéo quá nấc cao nhất chỉ xảy ra trong lúc kéo, dừng ngay khi thả tay.
  • Carousel không tự bật autoplay khi người dùng chọn giảm chuyển động.
// CSS: Sheet dùng motion-reduce:animate-none / motion-reduce:transition-none sẵn.
// JS: hành vi tự viết đọc trực tiếp sở thích của người dùng.
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");

list.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });

Nên & không nên

Nên
  • Dùng một Sheet cho cả desktop và điện thoại thay vì hai component rẽ nhánh thủ công.
  • Đặt hành động chính ở SheetFooter — trong vùng ngón cái và luôn nhìn thấy.
  • Luôn có nút đóng nhìn thấy được; cử chỉ chỉ là lối tắt.
  • Thử trên máy thật với thanh địa chỉ co giãn, bàn phím ảo và chế độ giảm chuyển động.
Không nên
  • Không dùng bottom sheet cho quyết định phá huỷ — dùng Dialog modalType="alert".
  • Không đặt quá 3 nấc; người dùng không nhớ được nhiều vị trí.
  • Không mở overlay chồng overlay (Sheet trong Sheet, Dialog trên Sheet) trừ xác nhận ngắn.
  • Không giấu thông tin bắt buộc ở phần dưới của nấc thấp — nó nằm ngoài màn hình.