Design skill
Design skill là file luật cho agent code. Token quyết định giá trị, còn skill quyết định cách ghép chúng lại: cỡ chữ, khoảng cách, góc bo, chuyển động. Agent đọc file này trước khi viết một màn hình.
1. Trong file có gì
Mỗi luật có một cách nên viết và một cách nên tránh. Chọn một luật để đọc đầy đủ.
Chữ
- 1. Chữ nội dung là 14px
- 2. Tiêu đề viết hoa kiểu câu
- 3. Không đổi khoảng cách chữ
- 4. Không dùng bold
- 5. Code trong dòng là 0.9em
Khoảng cách và căn chỉnh
- 6. Thứ liên quan nằm gần hơn thứ không liên quan
- 7. Padding dọc quanh chữ nhỏ hơn một bậc
- 8. Icon thẳng hàng với dòng chữ đầu tiên
Bề mặt
- 9. Lớp có bóng đổ dùng ring, không dùng viền
- 10. Góc lồng nhau đồng tâm
- 11. Đừng đặt Card trong Card
- 12. Thanh sticky được tách bằng viền
Chuyển động và trạng thái
- 13. Hover và nhấn chuyển dần trong 160ms
- 14. Mọi đổi trạng thái mà component tạo ra đều thấy trong chuyển động
- 15. Nội dung thu gọn giữ nguyên kích thước
- 16. Dialog luôn được mount và dùng `open`
Kích thước
Đang tải
Độ đục
Kính
Bố cục
2. Cài bằng CLI
Chạy npx werewolf-ui init một lần trong app được hỗ trợ. Lệnh cài .agents/skills/werewolf-design/SKILL.md, một phần do CLI quản lý trong AGENTS.md và .cursor/rules/werewolf.mdc. Cho agent đọc skill trước khi viết UI.
3. Cài thủ công
Nếu cài thủ công hoặc qua registry, copy file skills/werewolf-design/SKILL.md vào thư mục mà agent của bạn đọc luật. File cài vào dự án giữ nguyên tiếng Anh, vì agent đọc bản gốc. Phần dưới là bản tiếng Việt của các luật đó.
Các luật
Chữ
1. Chữ nội dung là 14px
Đoạn văn, giá trị trong input, ô bảng và control md (mặc định) cùng lg dùng 14px: text-body cho đoạn văn, text-sm bên trong control. 14px là cỡ mà cả thang chữ được chỉnh quanh, và trộn 14 với 15 hoặc 16 trong cùng một khối trông như một lỗi, không phải một phân cấp.
- 14px (
text-sm) dùng cho chữ phụ cạnh một dòng chính: mô tả, chữ gợi ý và chữ lỗi, controlsm. - 12px (
text-xs) dùng cho badge, token, chú thích và mốc thời gian. - 16px trở lên (
text-md,text-heading-*) chỉ dùng cho tiêu đề và controlxl.
// Do
<p className="text-body text-text-primary">Your trial ends in 12 days.</p>
// Avoid: body copy at 16px to "make it readable"
<p className="text-md text-text-primary">Your trial ends in 12 days.</p>2. Tiêu đề viết hoa kiểu câu
Chỉ viết hoa từ đầu tiên và danh từ riêng. Tên sản phẩm và tên tính năng giữ cách viết mà chủ của chúng dùng. Title Case và uppercase khiến tiêu đề đọc chậm hơn và to hơn nội dung bên dưới.
// Do
<h2 className="text-heading-3">Billing history</h2>
// Avoid
<h2 className="text-heading-3">Billing History</h2>
<h2 className="text-heading-3 uppercase">Billing history</h2>3. Không đổi khoảng cách chữ
Không dùng class tracking-* và không dùng letter-spacing trong CSS. Geist và JetBrains Mono đã được giãn đúng cho các cỡ trong thang; siết hoặc nới chúng là cách UI được sinh ra bắt đầu trông như do máy sinh.
// Do
<span className="text-xs font-medium text-text-secondary">Last 30 days</span>
// Avoid
<span className="text-xs font-medium tracking-wide uppercase text-text-secondary">Last 30 days</span>4. Không dùng bold
Thang có regular (400), medium (500) và semibold (600). Không có 700, và font-bold không có tác dụng trong một dự án Werewolf.
- Tiêu đề: độ đậm đi theo vai trò (
text-heading-1đến-3đã đặt sẵn). Đừng thêm class độ đậm lên trên. - Nhấn trong chữ, nhãn, chữ trên nút:
font-medium. font-semiboldchỉ khi medium không tách được một nhãn khỏi chữ medium ngay cạnh nó.
// Do
<p className="text-body">Payment failed. <span className="font-medium">Update your card</span> to keep access.</p>
// Avoid
<p className="text-body">Payment failed. <b>Update your card</b> to keep access.</p>5. Code trong dòng là 0.9em
Chữ mono nằm trong một câu sans trông to hơn các từ xung quanh khi cùng một cỡ. Dùng code-inline: nó đặt font mono ở 0.9em, nên nó theo đúng cỡ của câu.
// Do
<p className="text-body">Run <code className="code-inline">npm run build</code> again.</p>
// Avoid
<p className="text-body">Run <code className="font-mono">npm run build</code> again.</p>Khoảng cách và căn chỉnh
6. Thứ liên quan nằm gần hơn thứ không liên quan
Một tiêu đề và phần mô tả của nó là một đơn vị, nên khoảng cách giữa chúng nhỏ hơn khoảng cách tới đơn vị kế tiếp. Nếu mọi khoảng cách như nhau, người đọc không biết cái nào thuộc về cái nào.
- Tiêu đề tới mô tả, nhãn tới field, nhãn tới chữ gợi ý:
gap-xshoặcgap-sm. - Field tới field, header tới phần thân trong một card hoặc dialog:
gap-lg. - Nhóm tới nhóm:
gap-xl. Mục tới mục:gap-2xlhoặc hơn.
Khoảng cách bên trong luôn phải nhỏ hơn khoảng cách bên ngoài ít nhất một bậc.
// Do
<div className="flex flex-col gap-lg">
<div className="flex flex-col gap-xs">
<h3 className="text-heading-3">Webhooks</h3>
<p className="text-sm text-text-secondary">Send events to your server.</p>
</div>
<Button>Add endpoint</Button>
</div>
// Avoid: one gap for everything
<div className="flex flex-col gap-md">
<h3 className="text-heading-3">Webhooks</h3>
<p className="text-sm text-text-secondary">Send events to your server.</p>
<Button>Add endpoint</Button>
</div>7. Padding dọc quanh chữ nhỏ hơn một bậc
Chiều cao dòng đã thêm khoảng trống phía trên và dưới các chữ, nên padding đều mọi phía trông cao hơn bề ngang. Quanh chữ, dùng một bậc khoảng cách nhỏ hơn ở trên và dưới so với hai bên. Control có chiều cao cố định (h-element-*) đã xử lý việc này; luật này dành cho khối bạn tự đặt padding: banner, callout, dòng danh sách, ô bảng.
// Do
<div className="rounded-element bg-background-sunken px-lg py-md text-body">Changes are saved automatically.</div>
// Avoid
<div className="rounded-element bg-background-sunken p-lg text-body">Changes are saved automatically.</div>8. Icon thẳng hàng với dòng chữ đầu tiên
Icon cạnh chữ có thể xuống dòng nằm trong một hộp cao đúng một dòng, để nó căn giữa dòng đầu và đứng yên khi chữ xuống dòng. Căn nó vào giữa cả khối khiến nó trôi xuống giữa đoạn văn. Dùng cỡ icon khớp với chữ (12px với text-xs, 16px với text-sm, 20px với text-md trở lên) và color-icon-* cho màu của nó.
// Do
<div className="flex gap-sm text-body">
<span className="flex h-[1lh] shrink-0 items-center">
<IconInfoCircle size={16} className="text-icon-secondary" aria-hidden />
</span>
<p>Keys can't be viewed again after you close this dialog.</p>
</div>
// Avoid
<div className="flex items-center gap-sm text-body">
<IconInfoCircle size={16} className="text-icon-secondary" aria-hidden />
<p>Keys can't be viewed again after you close this dialog.</p>
</div>Bề mặt
9. Lớp có bóng đổ dùng ring, không dùng viền
Lớp nổi (menu, popover, dialog, toast) dùng ring-1 ring-border cho cạnh. Ring được vẽ cùng bóng đổ, nằm ngoài layout, nên cạnh vẫn sắc và kích thước lớp không đổi. Card nằm trên trang không có bóng và giữ border border-border.
// Do
<div className="rounded-container bg-background-raised ring-1 ring-border shadow-med">...</div>
// Avoid
<div className="rounded-container bg-background-raised border border-border shadow-med">...</div>Lớp kính theo cùng luật: material-thick với ring-1 ring-border và một shadow-* (luật 21).
Ở dark mode, các token shadow-* đã mang cạnh của lớp: một bóng tối hơn cộng một đường sáng 1px ở mặt trong cạnh trên. Đặt lớp trên bg-background-raised và dùng class bóng; đừng tự thêm viền sáng (không border-t sáng, không inset-shadow-*, không ring thêm), nếu không cạnh hiện hai lần. Lớp nào tự vẽ viền sáng cạnh thì đặt --werewolf-shadow-edge: 0 0 #0000 để bỏ viền có sẵn; các class material-* đã làm vậy.
Các biến thể Button glossy (glossy, destructive-glossy) là control duy nhất có bóng đổ, và chúng tự vẽ cạnh. Dùng biến thể đó; đừng dựng một control nổi từ các class shadow-* và gradient.
10. Góc lồng nhau đồng tâm
Khi một hình bo góc nằm trong hình khác và khe giữa các cạnh từ 8px trở xuống, bán kính ngoài bằng bán kính trong cộng khe đó. Nếu không, các góc bị bóp ở phía trong và trông không đều.
Với bán kính default, các bậc bán kính của Werewolf cách nhau 4px (radius-inner, radius-element, radius-container, radius-page), nên các bậc đồng tâm đúng khi khe là 4px (p-xs):
- radius-inner trong radius-element với khe 4px (một Token trong Input, một đoạn trong segmented control).
- radius-element trong radius-container với khe 4px (một mục menu trong menu).
radius-inner không bao giờ vượt 6px. Nên với bán kính round hoặc pill, hoặc bán kính px trên 10px, radius-inner trong radius-element không đồng tâm. Đó là ngoại lệ đã biết, và được chấp nhận. Chỗ các góc phải thẳng hàng đúng, hãy tính bán kính trong: max(outer - gap, 0).
Danh sách menu, select hoặc combobox có p-xs cộng viền 1px, nên các hàng nằm cách cạnh ngoài 5px. Dùng rounded-item (radius-item, bán kính container trừ 5px) cho các hàng đó, không dùng rounded-inner.
Với mọi khe khác tới 8px, tính bán kính trong từ bán kính ngoài. Với khe trên 8px, các góc đã đủ xa nên chỉ cần dùng bậc thấp hơn kế tiếp. Nếu hình ngoài là rounded-full, hình trong cũng là rounded-full.
// Do: 4px gap, steps line up
<div className="rounded-container p-xs ring-1 ring-border shadow-med bg-background-raised">
<button className="rounded-element px-md h-element-sm w-full text-left">Rename</button>
</div>
// Do: 8px gap, compute it
<div className="rounded-container p-sm">
<div className="rounded-[max(calc(var(--radius-container)-var(--spacing-sm)),0px)]">...</div>
</div>
// Avoid: 8px gap with the 4px-gap step
<div className="rounded-container p-sm">
<div className="rounded-element">...</div>
</div>11. Đừng đặt Card trong Card
Hai viền và hai lớp padding chồng vào nhau tốn chỗ và làm mờ cái nào được nhóm với cái nào. Trong Card, tách các phần bằng khoảng cách (luật 6), một đường chia (border-t border-border), hoặc một khối lõm (bg-background-sunken rounded-element). Nếu phần trong thực sự là một đối tượng riêng, phần ngoài có lẽ không nên là Card.
// Do
<Card>
<CardHeader>...</CardHeader>
<CardContent className="flex flex-col gap-lg">
<div className="rounded-element bg-background-sunken px-lg py-md">...</div>
</CardContent>
</Card>
// Avoid
<Card>
<CardContent>
<Card>...</Card>
</CardContent>
</Card>12. Thanh sticky được tách bằng viền
Header, toolbar hoặc footer sticky nhận border-b border-border (hoặc border-t cho footer) để tách khỏi nội dung đang cuộn bên dưới. Bóng đổ dành cho lớp nổi (luật 9), và thanh sticky không nổi. Nếu nội dung nên lộ xuyên qua, dùng material-thin, lớp này tự vẽ viền sáng cạnh và chuyển đặc ở chỗ thiếu độ mờ (luật 21).
// Do
<header className="sticky top-0 bg-background-page border-b border-border">...</header>
// Avoid
<header className="sticky top-0 bg-background-page shadow-low">...</header>Chuyển động và trạng thái
13. Hover và nhấn chuyển dần trong 160ms
Các thay đổi hover, nhấn và focus dùng duration-fast (160ms) với ease-standard. Trên bất kỳ thứ gì có overlay hover hoặc pressed, dùng transition-control: nó fade màu, bóng, tỉ lệ nhấn và overlay, vốn là một lớp gradient mà một background-color được liệt kê không fade được. Trên phần tử không có overlay, hãy liệt kê các thuộc tính màu của nó. Không bao giờ dùng transition-colors hoặc transition-all: chúng gồm outline-color, nên vòng focus sẽ fade vào thay vì hiện ngay. Bản thân vòng không bao giờ được animate.
// Do
<button className="transition-control duration-fast ease-standard hover:overlay-hover active:overlay-pressed motion-safe:active:scale-pressed focus-visible:focus-ring">...</button>
<a className="transition-[color] duration-fast ease-standard hover:text-text-primary focus-visible:focus-ring">...</a>
// Avoid
<a className="transition-colors duration-300 hover:overlay-hover">...</a>
<a className="transition-all hover:overlay-hover">...</a>
<a className="transition-[background-color] hover:overlay-hover">...</a> {/* the overlay still changes in one step */}Tôn trọng prefers-reduced-motion: màu vẫn có thể đổi, chuyển động (tỉ lệ nhấn, trượt) đặt sau motion-safe:.
14. Mọi đổi trạng thái mà component tạo ra đều thấy trong chuyển động
Một component chưa xong cho đến khi các đổi trạng thái nhìn thấy được của nó được animate. Phần tử tương tác (nút, toggle, checkbox, tab, nút đóng) dùng transition-control duration-fast ease-standard (luật 13), và control nhấn được thì thêm motion-safe:active:scale-pressed. Những thứ xuất hiện (dialog, sheet, popover, menu, tooltip, dòng mở rộng, toast) animate vào với ease-enter, duration-medium (duration-slow cho dialog và sheet, duration-fast cho tooltip), theo mẫu của Sheet:
<div className="transition-[translate,opacity] duration-slow ease-enter starting:opacity-0 motion-safe:starting:-translate-y-full">Chúng rời đi với data-[state=closed]:animate-exit-fade, -scale, -slide-top, -slide-right, -slide-bottom, -slide-left hoặc -collapse: keyframe dùng ease-exit, nhanh hơn lúc đến một bậc, và chỉ còn fade khi giảm chuyển động. Lớp unmount khi đóng chỉ còn trên trang khi một animation đang chạy, nên một transition không phát được đoạn thoát.
<div className="transition-[translate,opacity] duration-slow ease-enter starting:opacity-0 motion-safe:starting:-translate-y-full data-[state=closed]:animate-exit-slide-top">Popover hoặc menu lớn lên từ cạnh quay về phía trigger: data-[side=bottom]:origin-top data-[side=top]:origin-bottom data-[side=left]:origin-right data-[side=right]:origin-left. Dialog giữ ở giữa. Việc càng hay xảy ra thì càng ít chuyển động: command palette mở từ bàn phím thì mở ngay, và các tooltip đứng cạnh nhau nằm trong một TooltipGroup để cái kế tiếp mở ngay.
Khối chứa tĩnh (Card, Separator, Badge, Kbd, EmptyState, Metadata, Field) không cần chuyển động riêng, nhưng mọi phần tử tương tác mà chúng render phải mang chuyển động đó. Component mà các phần tương tác là component khác (Pagination dùng Button, ToggleGroup dùng Toggle, TableSelection dùng Checkbox) kế thừa chuyển động của chúng.
15. Nội dung thu gọn giữ nguyên kích thước
Khi một mục mở hoặc đóng, hãy animate chiều cao của lớp bọc có cắt (overflow-hidden), và để nội dung bên trong giữ đủ chiều rộng và chiều cao suốt thời gian đó. Đừng animate padding, cỡ chữ hoặc scale của nội dung, và đừng để nó reflow khi lớp bọc co lại; chữ xuống dòng lại giữa animation trông như hỏng. Dùng duration-medium, ease-enter để mở và ease-exit để đóng.
// Do: the wrapper animates, the content stays the same size
<div className="grid transition-[grid-template-rows] duration-medium ease-enter data-[state=closed]:grid-rows-[0fr] data-[state=open]:grid-rows-[1fr]">
<div className="overflow-hidden"><div className="pt-md">...</div></div>
</div>
// Avoid: padding and content shrink together
<div className="transition-all data-[state=closed]:h-0 data-[state=closed]:py-0">...</div>16. Dialog luôn được mount và dùng `open`
Render Dialog mọi lúc và hiện hoặc ẩn nó bằng open và onOpenChange. Chỉ mount khi nó cần hiện thì bỏ animation thoát, và có thể mất dấu chỗ focus phải trở về khi nó đóng.
// Do
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>...</DialogContent>
</Dialog>
// Avoid
{open && (
<Dialog open onOpenChange={setOpen}>
<DialogContent>...</DialogContent>
</Dialog>
)}Kích thước
17. Kích thước phần tử lấy từ 12 class
Chiều cao, chiều rộng của control và slot vuông như nút icon lấy đúng từ 12 class này, nên chúng thẳng hàng với Button và Input cùng cỡ. Component dùng cùng các bậc với prop size: sm, md (mặc định), lg, xl. Cảm ứng (pointer: coarse) đẩy md, lg và xl lên bậc trên; sm giữ nguyên vẻ ngoài.
| Bậc | Chiều cao | Chiều rộng | Chiều rộng và chiều cao | Chuột | Cảm ứng (`pointer: coarse`) |
|---|---|---|---|---|---|
| sm | h-element-sm | w-element-sm | size-element-sm | 28px | 28px, vùng chạm qua size-target-min |
| md | h-element-md | w-element-md | size-element-md | 32px | 36px |
| lg | h-element-lg | w-element-lg | size-element-lg | 36px | 44px |
| xl | h-element-xl | w-element-xl | size-element-xl | 44px | 48px |
Không hợp lệ: không có cỡ phần tử -xs (sm là control nhỏ nhất), không có dạng viết tắt h-sm, w-sm hoặc h-md. Hãy dùng các class này.
Mỗi bậc mang cỡ chữ và icon riêng: sm chữ 14px (text-sm) với icon 16px, md và lg 14px (text-sm) với icon 16px, xl 16px (text-md) với icon 20px. Dùng sm cho toolbar, bảng và menu dày, md cho mọi chỗ khác theo mặc định (kể cả panel prop: sm giữ vẻ nhỏ trên điện thoại), lg cho form thoáng và trang cài đặt, xl cho hành động chính và màn hình ưu tiên cảm ứng. UI dày hay thoáng không phải một cài đặt toàn cục: chọn bậc theo từng vùng, và mỗi vùng chỉ một bậc. Hai bậc kề nhau trên một hàng (Input md cạnh Button lg) chỉ lệch 4px khi dùng chuột và trông như một lỗi.
Control thấp hơn 44px trên cảm ứng (sm, md, checkbox, nút icon) giữ vẻ ngoài và nới vùng chạm tới size-target-min. Input và textarea không làm được: trên cảm ứng, Input sm là 28px và md là 36px. Trên màn hình ưu tiên cảm ứng, dùng Input lg hoặc xl, hoặc bọc field trong <label> có padding.
// Do: one step for the whole row
<div className="flex gap-sm">
<Input size="lg" aria-label="Email" />
<Button size="lg">Subscribe</Button>
</div>
// Avoid: neighbouring steps side by side
<div className="flex gap-sm">
<Input aria-label="Email" />
<Button size="lg">Subscribe</Button>
</div>Đang tải
18. Không để trống nội dung khi nội dung tiếp theo đang tải
Khi người dùng đổi trang, tab, bộ lọc hoặc trang của một bảng, hãy giữ những gì đang ở trên màn hình cho đến khi nội dung tiếp theo sẵn sàng. Thực hiện thay đổi trong một transition (useTransition) và đánh dấu vùng là pending: pending trên TabsContent và Table làm mờ nó tới opacity-pending và đặt aria-busy. Chỉ hiện Skeleton khi không có gì để giữ (lần tải đầu), chỉ sau khoảng 150ms, và ít nhất 300ms sau khi đã hiện (useSkeletonDelay), để nó không nháy. Dựng skeleton theo hình nội dung: dòng chữ cùng cỡ font (mỗi dòng cao h-lh), control cùng size (shape="control"), cùng các góc. Bọc nó trong SkeletonGroup, thứ đặt aria-busy và nói "Loading" trong một status polite. Nhịp pulse chạy theo duration-loop và dừng khi giảm chuyển động.
// Do
const [isPending, startTransition] = useTransition()
<Tabs value={tab} onValueChange={(next) => startTransition(() => setTab(next))}>
<TabsContent value={tab} pending={isPending}>...</TabsContent>
</Tabs>
// Avoid
{isLoading ? <Spinner /> : <TabsContent value={tab}>...</TabsContent>}19. Ảnh giữ chỗ và fade vào
Giữ chỗ của ảnh trước khi nó tải (ratio, một class tỉ lệ hoặc một chiều cao), hiện ngay một bản mờ nhỏ của nó, và fade ảnh đầy đủ vào phía trên khi đã tải xong. Dùng Image với placeholderSrc: một data URL vài trăm byte được tạo lúc thêm ảnh, nên không cần request. Trong app Next.js, next/image với placeholder="blur" làm cùng việc cho ảnh tĩnh. Không fade khi giảm chuyển động.
// Do
<Image src={post.cover} placeholderSrc={post.coverBlur} alt={post.coverAlt} ratio="16 / 9" />
// Avoid
<img src={post.cover} alt={post.coverAlt} />Độ đục
20. Độ đục lấy từ ba token vai trò
Không có thang độ đục. Dùng một trong ba token, theo việc làm mờ để làm gì: opacity-pending trên nội dung được giữ trên màn hình khi nội dung tiếp theo đang tải (pending trên TabsContent và Table đã áp dụng nó), opacity-disabled trên nội dung không phải chữ trong control bị tắt mà không token nào đặt màu (một ô màu, một ảnh, một avatar ảnh), opacity-subtle trên hình trang trí nên lùi lại. Không viết bậc khác (opacity-50, opacity-[.7]) hoặc alpha trên một class màu (text-text-primary/60, bg-accent/50). Fade một thứ vào hoặc ra thì đi giữa opacity-0 và opacity-100; việc đó ẩn hoặc hiện nó, không làm mờ nó.
Không dùng độ đục để làm chữ nhạt hơn. Chữ phụ dùng text-text-secondary (gợi ý và metadata dùng text-text-tertiary), đã được tính để giữ tương phản trên mọi nền; chữ ở một độ đục lấy bất kỳ thứ gì phía sau và không có tương phản đã kiểm. Nhãn và icon bị tắt dùng text-text-disabled và text-icon-disabled, không dùng opacity-disabled.
// Do
<p className="text-sm text-text-secondary">Updated 5 minutes ago</p>
<span className="size-4 rounded-inner opacity-disabled" style={{ background: value }} />
// Avoid
<p className="text-sm text-text-primary opacity-60">Updated 5 minutes ago</p>
<button disabled className="disabled:opacity-50">Save</button>Kính
21. Kính chỉ trên lớp nổi, tối đa hai
Kính là một class cho mỗi mức: material-thin cho các thanh (header, thanh nav, thanh tab), material-thick cho sheet, popover, dialog và menu, material-refract chỉ cho phần tử có kích thước cố định (thanh tab, nút hành động nổi, thanh nav). Class vẽ phần nền, độ mờ, viền sáng cạnh và đỉnh, cùng các nền đặc dự phòng; đừng tự dựng kính từ bg-*, backdrop-blur-* và một màu alpha. Nền của nó chỉ đủ đục để text-text-primary và text-text-secondary giữ 4.5:1 trên bất kỳ thứ gì phía sau, nên chỉ dùng hai token đó cho chữ trên kính.
- Tối đa hai lớp kính hiện cùng lúc trên một màn hình. Không kính chồng kính quá mức đó, nên một menu trên một sheet kính là giới hạn.
- Chỉ lớp nổi và lớp overlay. Không bao giờ nền trang, không bao giờ một Card, không bao giờ bên trong danh sách cuộn dài hoặc trên các dòng của danh sách.
material-refractchỉ trên phần tử có kích thước và bán kính góc không bao giờ đổi. Thứ nào đổi kích thước, reflow theo nội dung hoặc animate kích thước thì dùngmaterial-thick. Khúc xạ chỉ vẽ trên trình duyệt nền Chromium, sau khienableMaterialRefraction()từ mục registrymaterialđánh dấu root; mọi nơi khác nó trông đúng hệtmaterial-thick. Trước khi một component dùng nó, hãy kiểm tra cuộn và animation bằng tay trên một iPhone đời cũ và một điện thoại Android cấu hình thấp.- Khi giảm chuyển động, không có viền sáng được animate: thứ nào làm viền sáng chuyển động thì đặt sau
motion-safe:.
// Do
<SheetContent className="material-thick ring-1 ring-border shadow-high">...</SheetContent>
<header className="sticky top-0 material-thin">...</header>
// Avoid
<div className="bg-background-raised/60 backdrop-blur-lg">...</div>
<li className="material-thin">...</li>Bố cục
22. Nội dung app nằm trong một surface inset
Một màn app có Sidebar đặt Sidebar trên nền canvas của trang và đặt nội dung trang trong một surface bên cạnh, để nội dung đọc như một tấm và điều hướng lùi lại phía sau. <Sidebar inset> bỏ nền và cạnh của Sidebar; <SidebarInset> là surface: một main có bán kính page, có viền, cách mép canvas 8px và đệm 32px (p-2xl), tràn viền với p-lg dưới md.
- SidebarInset giữ đệm của trang. Đừng đệm nội dung lần nữa và đừng bọc cả trang bằng một Card: nó đã là surface.
- Tách các section bên trong bằng
gap-2xl, bằng đúng đệm của nó. - Card trên đó giữ
border border-bordervà không có bóng. Các metric liên quan dùng chung một khung có viền, chia bằngborder-shoặcborder-t, không phải một hàng Card nhỏ.
// Do
<SidebarProvider>
<div className="flex h-dvh bg-background-page">
<Sidebar inset>...</Sidebar>
<SidebarInset className="gap-2xl">
<h1 className="text-heading-2">Overview</h1>
<section className="grid grid-cols-3 rounded-container border border-border">...</section>
</SidebarInset>
</div>
</SidebarProvider>
// Avoid
<Sidebar>...</Sidebar>
<main className="p-8"><Card className="p-6">...whole page...</Card></main>Trước khi trả UI
- Mọi giá trị là một class token; không hex, không px ngoài các giá trị arbitrary mà các luật trên chỉ.
- Không
font-bold, khôngtracking-*, không tiêu đề Title Case. - Không đặt
bordercùng mộtshadow-*trên cùng một phần tử. - Tối đa một Button glossy mỗi view, và không bao giờ cạnh một Button solid:
glossythaysolid, không thêm một mức. - Khoảng cách trong nhỏ hơn khoảng cách ngoài.
- Bán kính lồng nhau theo luật 10.
- Mọi đổi trạng thái nhìn thấy được đều có chuyển động: transition màu, vào bằng ease-enter, ra bằng ease-exit, chuyển động đặt sau
motion-safe:(luật 14). - Kích thước phần tử lấy từ 12 class và prop
size, một bậc mỗi vùng (luật 17). - Không gì trên màn hình bị trống khi nội dung tiếp theo đang tải; skeleton khớp bố cục mà nó đứng thay (luật 18).
- Không class độ đục nào khác ngoài opacity-pending, opacity-disabled, opacity-subtle (và
opacity-0/opacity-100cho một fade), không alpha màu như/60, và không làm chữ nhạt bằng độ đục (luật 20). - Tối đa hai lớp kính trên một màn hình, chỉ trên lớp nổi,
material-refractchỉ trên phần tử có kích thước cố định (luật 21). - Một màn app có Sidebar đặt nội dung trong
SidebarInsetcạnh<Sidebar inset>, chỉ đệm một lần (luật 22).