--- description: Hướng dẫn Agent tự động tạo UI Component Bottom Sheet dựa trên Design Tokens, chuẩn Material 3 detent system & iOS HIG sheet pattern. Component ĐỘC LẬP — không phải variant của Drawer.
Bottom Sheet Component Generation Skill
Skill này hướng dẫn bạn (AI Agent) tạo component Bottom Sheet — panel nội dung trượt lên từ đáy màn hình, có hệ thống detent heights, drag handle, và slot-based content. Tách biệt hoàn toàn khỏi Drawer.
1. Mục tiêu (Objective)
Tạo component Bottom Sheet hỗ trợ 4 variants (modal, standard, persistent, fullscreen), detent system (peek/medium/large/full), drag handle resize, swipe-to-dismiss, slot-based content injection, và focus trap. Tích hợp 100% Design Tokens.
2. AI Context & Intent (Ngữ cảnh cho AI)
Khi nào dùng Bottom Sheet?
- Action sheet: Share, export, print options (modal)
- Filter & sort: Chips, sliders, date range (modal)
- Detail preview: Quick view sản phẩm, place info (modal)
- Quick form: 2-3 fields input nhanh (modal)
- Search & select: Keyboard + list dài (fullscreen)
- Map detail: Persistent info panel (standard)
- Music player / tracker: Always visible (persistent)
⚠️ Phân biệt Bottom Sheet vs Drawer vs Modal vs Dialog (QUAN TRỌNG)
| Tiêu chí | Bottom Sheet | Drawer | Modal | Dialog |
|---|---|---|---|---|
| Position | Bottom edge, slide up | Side edge (left/right) | Center overlay | Center overlay |
| Hướng | Vertical only (↑↓) | Horizontal (←→) or vertical | None (centered) | None (centered) |
| Detents | ✅ (peek/medium/large/full) | ❌ | ❌ | ❌ |
| Drag handle | ✅ Optional | ❌ | ❌ | ❌ |
| Swipe dismiss | ✅ Swipe down | ❌ | ❌ | ❌ |
| Content | Contextual / supplementary | Navigation / long form | Complex form / editor | System feedback |
| Mobile | Native pattern | Hamburger menu | Fullscreen or center | Center |
| Tương đồng | M3 BottomSheet, iOS Sheet |
M3 NavigationDrawer |
M3/iOS Dialog |
M3 AlertDialog |
Decision Tree cho AI
Cần overlay ở đáy màn hình?
├─ Actions/share (3+ items) → Bottom Sheet (modal, medium)
├─ Filter/sort controls → Bottom Sheet (modal, medium→large)
├─ Detail preview + CTA → Bottom Sheet (modal, large)
├─ Quick form (2-3 fields) → Bottom Sheet (modal, large)
├─ Search + keyboard + list → Bottom Sheet (fullscreen)
├─ Map detail / persistent info → Bottom Sheet (standard)
├─ Always-visible tracker → Bottom Sheet (persistent)
├─ Side navigation (mobile) → Drawer (left)
├─ Detail/edit panel (desktop) → Drawer (right)
├─ Complex multi-field form → Modal
├─ Confirm/alert (1-2 actions) → Dialog
└─ Transient notification → Toast
3. Ngữ nghĩa & Phân loại (Semantics)
3.1. Variants
| Variant | Mô tả | Scrim | Detents | Use case |
|---|---|---|---|---|
modal |
Overlay + scrim, blocks background | ✅ | medium, large | Action sheets, filters, quick forms |
standard |
Coexists with background, no scrim | ❌ | peek, medium, large | Map details, contextual info |
persistent |
Always visible, snaps to detents | ❌ | peek, medium, large | Music player, delivery tracker |
fullscreen |
100% screen, no radius, app bar header | ✅ | full only | Search, long form, media view |
3.2. Detent Heights (% screen)
| Detent | % Screen | CSS Value | When to use |
|---|---|---|---|
peek |
25% | max-height: 25% |
Peek — summary, 1-2 items |
medium |
50% | max-height: 50% |
Default modal open (M3 + iOS standard) |
large |
92% | max-height: 92% |
Expanded — M3 top margin 72dp |
full |
100% | max-height: 100%; border-radius: 0 |
Fullscreen variant |
Rule: Không dùng fixed px cho height — luôn dùng % of container để responsive.
3.3. Sub-components (Slots)
┌─────────────────────────────────┐
│ Drag Handle │ ← .surface-sheet__handle (optional), 32×4px
│─────────────────────────────────│
│ Header Title [Close] │ ← .slot-header
│ Subtitle │
│─────────────────────────────────│ ← divider (--border)
│ │
│ Scrollable Content │ ← .slot-body (SLOT injection point)
│ (list / form / custom) │
│ │
│─────────────────────────────────│
│ [Secondary] [Primary] │ ← .slot-footer (optional)
│─────────────────────────────────│
│ safe area padding │
└─────────────────────────────────┘
Multi-row Header (Search / Filter pattern)
Khi header cần chứa controls (search bar, filter chips) — dùng __header-row sub-component:
┌─────────────────────────────────┐
│ Drag Handle │ ← .surface-sheet__handle
│─────────────────────────────────│
│ Header Title [Close] │ ← .surface-bottomsheet__header-row
│ [🔍 Search input... ] │ ← search/filter controls (pinned)
│─────────────────────────────────│ ← auto divider via border-bottom
│ │
│ Scrollable Results │ ← .slot-body (only results scroll)
│ │
└─────────────────────────────────┘
Rule: Search/filter controls PHẢI nằm TRONG header (pinned), KHÔNG trong body (scrollable). Chuẩn iOS Sheet + Material 3.
Tái sử dụng __header-row
Pattern __header-row áp dụng cho bất kỳ sheet nào cần multi-row header. Chỉ cần:
- Wrap title + close trong
.surface-bottomsheet__header-row - Thêm controls bên dưới (search, filter chips, tabs, v.v.)
CSS tự kích hoạt column layout qua :has(.surface-bottomsheet__header-row).
Ví dụ use cases:
| Use case | Controls trong header | Ví dụ |
|---|---|---|
| Search | search-bar |
Search transactions, contacts... |
| Filter | chip chips row |
All · Income · Expenses · Pending |
| Tabs | tabs component |
Transfers · Payments · Requests |
| Search + Filter | search-bar + chip row |
Search + category chips |
<!-- Generic pattern — bất kỳ sheet nào -->
<div class="surface-bottomsheet__header">
<div class="surface-bottomsheet__header-row">
<span class="surface-bottomsheet__title">Title</span>
<button class="surface-bottomsheet__close">✕</button>
</div>
<!-- Thêm bất kỳ controls nào ở đây — sẽ pinned, không scroll -->
<div class="search-bar">...</div>
<div class="flex gap-xs scroll-x">
<button class="chip chip--primary">All</button>
<button class="chip chip--secondary">Category</button>
</div>
</div>
<div class="surface-bottomsheet__body">
<!-- Chỉ phần này scroll -->
</div>
| Part | Class | Mô tả |
|---|---|---|
| Handle | .surface-sheet__handle |
Drag handle chip — kéo resize giữa detents |
| Header | .slot-header |
Title + close button + optional subtitle |
| Header Row | .surface-bottomsheet__header-row |
Row chứa title + close khi header là multi-row (column layout) |
| Body | .slot-body |
Scrollable content area — slot injection point |
| Footer | .slot-footer |
Action buttons — sticky bottom |
| Scrim | .surface-sheet__scrim |
Background overlay (modal only) |
Slot Architecture:
.slot-bodylà injection point. JS switches content bằng slot name → inject HTML tương ứng. Mỗi slot define:title,subtitle,content,showFooter,footerButtons.
3.4. Slot Map (Figma ↔ Code)
📎 Source:
slot-manifest.json→bottom-sheet· Layer: surface
| Figma Slot | data-slot |
CSS Class | Required | Accepts |
|---|---|---|---|---|
| Root | bottom-sheet |
.surface-sheet |
✅ | — |
| Handle | bottom-sheet-handle |
.surface-sheet__handle |
❌ | drag-handle |
| Header | bottom-sheet-header |
.slot-header |
❌ | title-group |
| Header Row | bottom-sheet-header-row |
.surface-bottomsheet__header-row |
❌ | title + close (multi-row) |
| Body | bottom-sheet-body |
.slot-body |
✅ | list, form, search, * |
| Footer | bottom-sheet-footer |
.slot-footer |
❌ | button-group |
| Scrim | bottom-sheet-scrim |
.surface-sheet__scrim |
❌ (modal only) | overlay |
<!-- HTML skeleton with data-slot -->
<div class="surface-sheet surface-sheet--modal" data-slot="bottom-sheet" role="dialog" aria-modal="true">
<div class="surface-sheet__handle" data-slot="bottom-sheet-handle"></div>
<div class="slot-header" data-slot="bottom-sheet-header">
<span data-slot="bottom-sheet-title">Title</span>
<button data-slot="bottom-sheet-close" aria-label="Close">✕</button>
</div>
<div class="slot-body" data-slot="bottom-sheet-body">
<!-- scrollable content -->
</div>
<div class="slot-footer" data-slot="bottom-sheet-footer">
<button class="btn btn--outline">Cancel</button>
<button class="btn btn--primary">Confirm</button>
</div>
</div>
4. Token Mapping
📦 Token values: Xem
ATOMIC-MAPPING.md— mục bottom-sheet cho complete mapping.
| Token | Type | Value | Mô tả |
|---|---|---|---|
| bg | color | {base.surface} |
Container background |
| fg | color | {base.surface-foreground} |
Container text |
| radius | number | {radius.3xl} |
28px top corners (M3 spec) |
| radius.fullscreen | number | 0 |
No radius when fullscreen |
| shadow | shadow | {elevation.shadow-md} |
Level-1 elevation |
| handle.bg | color | {base.muted-foreground} |
Drag handle color @ opacity 0.4 |
| handle.width | number | 32px |
M3 drag handle spec |
| handle.height | number | 4px |
M3 drag handle spec |
| handle.radius | number | {radius.full} |
chip shape |
| handle.hit-area | number | 48px |
Min touch target (M3) |
| scrim | color | {base.background-overlay} |
Scrim for modal variant |
| header.title.font-size | number | {font.size.lg} |
18px — overlay title (UX T11) |
| header.title.font-weight | number | {font.weight.semibold} |
Semibold title |
| header.subtitle.fg | color | {base.muted-foreground} |
Subtitle color |
| close.fg | color | {base.muted-foreground} |
Close button icon |
| close.hover | color | {state-layer.hover} |
Close button hover state |
| close.size | number | 32px |
Close button dimensions |
| divider | color | {base.border} |
Header/footer separator |
| footer.border | color | {base.border} |
Footer top separator |
| safe-area | env | env(safe-area-inset-bottom) |
iOS home indicator |
| max-width | number | 640px |
M3 responsive — tablet/desktop |
| transition.open | string | {duration.medium-2} {easing.standard-decelerate} |
Slide-up animation |
| transition.close | string | {duration.fast-2} {easing.standard-accelerate} |
Slide-down animation |
| z-index | number | {z-index.drawer} |
1300 — stacking order |
| disabled.bg | color | {base.muted} |
Disabled state bg |
| disabled.fg | color | {base.muted-foreground} |
Disabled state fg |
| focus.ring | color | {focus.ring-color} |
Focus ring color |
Height Standards (Cross-platform)
| Metric | M3 | Apple HIG | Prisma |
|---|---|---|---|
| Min height (standard) | 56dp | — | 7% |
| Initial open (modal) | 50% | 50% (.medium) |
50% |
| Expanded | screen − 72dp (~92%) | ~100% (.large) |
92% |
| Fullscreen | 100% | 100% | 100% |
5. Props & API
interface BottomSheetProps {
/** Variant — determines behavior & scrim */
variant?: 'modal' | 'standard' | 'persistent' | 'fullscreen';
/** Controlled open state */
open: boolean;
/** Close callback */
onClose: () => void;
/** Available detent heights */
detents?: ('peek' | 'medium' | 'large' | 'full')[];
/** Initial detent when opening */
initialDetent?: 'peek' | 'medium' | 'large';
/** Show drag handle */
showHandle?: boolean; // default: true (false for fullscreen)
/** Close on scrim tap (modal only) */
closeOnOverlayClick?: boolean; // default: true
/** Close on Escape key */
closeOnEscape?: boolean; // default: true
/** Swipe to dismiss */
swipeToDismiss?: boolean; // default: true
/** Lock background scroll (modal only) */
lockBackgroundScroll?: boolean; // default: true
/** Max width (tablet/desktop) */
maxWidth?: string; // default: '640px'
/** Children content */
children: ReactNode;
}
interface BottomSheetHeaderProps {
/** Title */
title: string;
/** Subtitle */
subtitle?: string;
/** Show close button */
showCloseButton?: boolean; // default: true
}
interface BottomSheetFooterProps {
children: ReactNode; // Buttons
}
6. Accessibility (a11y)
| Requirement | Implementation |
|---|---|
| Role | role="dialog" (modal/fullscreen), role="complementary" (standard/persistent) |
| ARIA | aria-modal="true" (modal), aria-label or aria-labelledby → title |
| Focus trap | Modal/fullscreen: focus trapped inside. Standard/persistent: no trap |
| Initial focus | First focusable element or close button |
| Return focus | Khi close → trả focus về trigger element |
| Escape | Close modal/fullscreen sheet |
| Drag handle | role="slider", aria-label="Resize sheet", focusable in tab order |
| Screen reader | Announce "Sheet opened" / "Sheet closed" via live region |
| Touch target | Drag handle hit area: 48px. Close button: ≥44×44px |
| Reduced motion | @media (prefers-reduced-motion: reduce) → instant show/hide, no slide |
| Tab order | Focusable elements trong sheet giữ đúng tab order |
7. Best Practices & Rules
- Content: Bottom sheet CHỈ cho supplementary content — KHÔNG dùng cho primary content.
- Actions rule: ≥ 3 actions → bottom sheet. 1-2 → dialog/menu.
- No stacking: KHÔNG stack multiple sheets lên nhau.
- Scroll vs dismiss: Content ở top → swipe dismisses. Content đã scroll → scroll first, rồi mới dismiss.
- Drag handle: Luôn hiện cho modal/standard. Ẩn cho fullscreen.
- Button size: Footer buttons PHẢI cùng size (BS-3). Mobile →
btn-lg.btn--block. Xembutton.md§4.7. - Portal: Render qua Portal vào
<body>— tránh z-index/overflow issues. - Responsive: Desktop (≥840px) → tự chuyển thành Dialog hoặc Side Panel.
- Không Hardcode: Mọi giá trị từ Token. KHÔNG hardcode px/colors.
- Icon: Close button (
x) CHỈ dùng Lucide icon từassets/icons/. CẤM dùng text emoji. Xemicon.mdmục 10.
7.1. Overlay Placement Rule (B11)
Sheet + scrim PHẢI là direct children của device-frame (hoặc body/portal), KHÔNG nằm bên trong container có clip-path hoặc overflow: hidden.
z-index hierarchy chuẩn:
| Layer | z-index | Elements |
|---|---|---|
| OS Chrome | 999 | Dynamic Island, Home Indicator |
| Surface | 60 (--local-z-surface) |
Bottom sheet, Modal, Dialog, Drawer |
| Scrim | 50 (--local-z-scrim) |
Background overlay |
| Ground | 10 (--local-z-ground) |
Status bar, App bar, Bottom nav |
⚠️ Anti-pattern: Đặt sheet/scrim inside
.app-screen-containercóclip-path: inset(0)→ bị clip, không phủ được status bar và bottom nav.
7.2. Safe Area Bottom (B12)
Bottom sheet phủ xuống đáy device — footer buttons bị home indicator che. PHẢI thêm safe area:
/* Footer có safe area */
.surface-bottomsheet__footer {
padding-bottom: calc(var(--section-padding-default) + var(--local-iphone-safe-area-bottom));
}
/* Sheet không có footer → body cần safe area */
.surface-bottomsheet__body:last-child {
padding-bottom: var(--local-iphone-safe-area-bottom);
}
7.3. Divider-Aware Body Padding (B13)
Áp dụng cho: Sheet, Drawer, Modal — mọi surface có header/body/footer slots.
Rule: Khi header hoặc footer có visible border (divider), body PHẢI có padding-y tương ứng để content không chạm divider. Khi KHÔNG có divider → padding-y = 0.
| Divider | Body padding | Lý do |
|---|---|---|
Header có border-bottom |
padding-top: var(--section-padding-default) |
Breathing room giữa divider và content |
Footer có border-top |
padding-bottom: var(--section-padding-default) |
Breathing room trước footer |
| Không có divider | padding-top: 0; padding-bottom: 0 |
Content flow tự nhiên vào spacing |
CSS mechanism (auto-detect via adjacent sibling + :has()):
/* Sheet: header with divider → body gets padding-top */
.surface-bottomsheet__header:has(.surface-bottomsheet__header-row) + .surface-bottomsheet__body {
padding-top: var(--section-padding-default);
}
/* Drawer: same pattern (future-ready) */
.surface-drawer__header[style*="border-bottom"] + .surface-drawer__body {
padding-top: var(--section-padding-default);
}
⚠️ Anti-pattern: Content body chạm trực tiếp vào divider → thiếu breathing room → visual noise, khó đọc.
❌ Anti-patterns
| Don't | Do | Why |
|---|---|---|
| Fixed px heights | Percentage-based detents | Responsive across devices |
| Stack sheets | Close first, open second | Cognitive overload |
| Primary content in sheet | Use full page instead | Sheet = supplementary only |
| Skip drag handle on modal | Always show handle | User expects swipe behavior |
| Nest drawer inside sheet | Redesign UX | Too many layers |
| Hardcode 28px radius | Use var(--radius-3xl) |
Token system |
| Body content chạm divider | Auto padding-y khi có divider (B13) | Breathing room, visual clarity |
8. Example Usage
{/* Modal — share actions */}
<BottomSheet
open={isOpen}
onClose={close}
variant="modal"
detents={['medium']}
showHandle
>
<BottomSheet.Header title="Share" subtitle="Choose how to share" />
<BottomSheet.Body>
<ShareGrid items={shareTargets} />
<ActionList items={['Copy Link', 'Save', 'Print']} />
</BottomSheet.Body>
</BottomSheet>
{/* Modal — filter with footer */}
<BottomSheet
open={isOpen}
onClose={close}
variant="modal"
detents={['medium', 'large']}
>
<BottomSheet.Header title="Filter & Sort" subtitle="3 filters active" />
<BottomSheet.Body>
<FilterChips categories={['Food', 'Transport', 'Shopping']} />
<PriceRange min={0} max={1000} />
</BottomSheet.Body>
<BottomSheet.Footer>
<Button variant="outline" onClick={reset}>Reset</Button>
<Button variant="primary" onClick={apply}>Apply Filters</Button>
</BottomSheet.Footer>
</BottomSheet>
{/* Fullscreen — search */}
<BottomSheet
open={isOpen}
onClose={close}
variant="fullscreen"
>
<BottomSheet.Header title="Search Location" />
<BottomSheet.Body>
<SearchInput placeholder="Search locations..." />
<LocationList items={locations} />
</BottomSheet.Body>
</BottomSheet>
{/* Standard — map detail (non-modal) */}
<BottomSheet
open={isOpen}
onClose={close}
variant="standard"
detents={['peek', 'medium', 'large']}
initialDetent="peek"
>
<BottomSheet.Header title="Vincom Center" subtitle="72 Lê Thánh Tôn" />
<BottomSheet.Body>
<PlaceDetail place={selectedPlace} />
</BottomSheet.Body>
<BottomSheet.Footer>
<Button variant="outline" onClick={share}>Share</Button>
<Button variant="primary" onClick={navigate}>Directions</Button>
</BottomSheet.Footer>
</BottomSheet>
9. CSS Naming Convention (Elevation-based)
.surface-sheet ← Container (Surface layer)
.surface-sheet--modal ← Modal variant (scrim + focus trap)
.surface-sheet--standard ← Standard variant (no scrim)
.surface-sheet--persistent ← Persistent variant (always visible)
.surface-sheet--fullscreen ← Fullscreen variant (radius:0, 100%)
.surface-sheet--open ← Open state (translateY: 0)
.surface-sheet--detent-peek ← max-height: 25%
.surface-sheet--detent-medium ← max-height: 50%
.surface-sheet--detent-large ← max-height: 92%
.surface-sheet--detent-full ← max-height: 100%
.surface-sheet__handle ← Drag handle (32×4px chip)
.surface-sheet__header-row ← Title + close row (inside multi-row header)
.surface-sheet__scrim ← Background overlay (modal only)
.surface-sheet__close ← Close button
.slot-header ← Header area (generic slot)
.slot-body ← Scrollable content (generic slot)
.slot-footer ← Action buttons (generic slot)
10. Cross-reference
| Related | File |
|---|---|
| Research | knowledge/research/bottom-sheet-component-research.md |
| Atomic mapping | ATOMIC-MAPPING.md → bottom-sheet section |
| Drawer (SEPARATE comp) | drawer.md — side panels, NOT bottom sheet |
| Modal | modal.md — center overlay |
| Dialog | dialog.md — system feedback |
| Demo | design-system/tokens/density/demo-bottom-sheet.html |