Chỉ báo focus
Khi điều khiển bằng bàn phím, phần tử đang focus luôn có viền 2px tương phản cao — còn màu của control thì giữ nguyên.
Cách hoạt động
Một quy tắc toàn cục trong index.css vẽ outline cho :focus-visible — trình duyệt chỉ bật nó khi người dùng di chuyển bằng bàn phím, không bật khi click chuột.
/* src/index.css — @layer base: áp cho MỌI phần tử nhận focus bằng bàn phím */
:focus-visible {
outline: 2px solid var(--colorStrokeFocus2); /* đen ở light, trắng ở dark */
outline-offset: 1px;
border-radius: var(--borderRadiusMedium);
}Utility tương ứng là outline-stroke-focus (token colorStrokeFocus2). Radio vẽ vòng quanh cả nút lẫn nhãn bằng has-[button:focus-visible] để vùng chỉ báo khớp vùng bấm.
Vì sao màu của control không đổi khi focus
Fluent tách focus thành một kênh riêng: hover / pressed / selected đổi màu, còn focus chỉ thêm viền.
| Trạng thái | Tín hiệu | Lý do |
|---|---|---|
| Rest → Hover → Pressed | Nền / viền đậm dần (X → XHover → XPressed) | Phản hồi cho con trỏ. |
| Selected / Checked | Brand (nền, vạch chỉ báo, chữ semibold) | Trạng thái lâu dài của dữ liệu. |
| Focus (bàn phím) | Outline 2px đen / trắng, offset 1px | Vị trí hiện tại — phải kết hợp được với mọi trạng thái trên. |
| Disabled | Màu disabled, không opacity | Không tương tác. |
- Một tab vừa được chọn vừa đang focus vẫn đọc được cả hai: vạch brand ở đáy và viền focus bao ngoài.
- Đen / trắng luôn đạt ≥ 3:1 với mọi nền neutral của theme, bất kể brand là Sakura, Web hay brand riêng — màu brand thì không đảm bảo điều đó.
- Đổi màu khi focus dễ bị nhầm với hover hoặc pressed, khiến người dùng tưởng đã bấm.
Ô nhập & mục trong danh sách
Hai ngoại lệ có chủ đích, cả hai đều thay outline bằng một chỉ báo tương đương.
- Ô nhập (Input, Textarea, Select, Dropdown, Combobox, TagPicker, SpinButton, DatePicker): thanh brand 2px chạy từ giữa ra ở đáy, theo
:focus-within— hiện cả khi click, vì người dùng cần biết ô nào đang nhận chữ. Thẻ<input>bên trong đặtoutline-nonevì đã có chỉ báo thay thế. - Mục trong widget tổng hợp (item của Menu, option của Dropdown / Combobox, mục Command): focus nằm ở item đang highlight, chỉ báo bằng nền
background-1-hovertheodata-highlighted/data-selected.
Phần tử tự viết
Vì quy tắc nằm ở base, phần tử tương tác tự viết có vòng focus miễn phí. Việc của bạn là đừng vô tình xoá nó.
Danh sách nằm trong khung overflow-hidden: -outline-offset-2 kéo viền vào trong để không bị cắt.
{/* Phần tử tự viết tự có vòng focus nhờ base style — chỉ cần tabIndex và role đúng */}
<div
role="button"
tabIndex={0}
onClick={open}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open();
}
}}
className="flex items-center gap-2 rounded-medium p-3 hover:bg-subtle-hover"
>
…
</div>
{/* Trong khung overflow-hidden (list, card bo góc): kéo outline vào trong để không bị cắt */}
<a href="/files/42" className="block px-3 py-2 hover:bg-subtle-hover focus-visible:-outline-offset-2">…</a>
{/* Muốn đổi kiểu viền thì THAY bằng một chỉ báo tương đương, không xoá */}
<button className="outline-none focus-visible:ring-2 focus-visible:ring-stroke-focus focus-visible:ring-offset-1">…</button>- Dùng focus-visible: cho mọi style chỉ dành cho bàn phím.
- Kéo outline vào trong (-outline-offset-2) khi phần tử nằm sát mép khung overflow-hidden.
- Nếu thay outline, dùng chỉ báo tương đương: ≥ 2px, tương phản ≥ 3:1, bao quanh phần tử.
- Không dùng outline-none / focus:outline-none mà không có chỉ báo thay thế.
- Không dùng focus: cho viền — nó hiện cả khi click chuột.
- Không chỉ đổi màu nền hay màu brand làm tín hiệu focus duy nhất.
Quản lý focus: dialog, menu, popover
Radix lo phần khó: giữ focus trong Dialog / Drawer, đưa focus vào bề mặt khi mở và trả về trigger khi đóng.
Mở bằng Enter, đóng bằng Esc — focus quay lại nút.
function RenameDialog() {
const nameRef = React.useRef<HTMLInputElement>(null);
return (
<Dialog>
<DialogTrigger asChild>
<Button>Đổi tên</Button>
</DialogTrigger>
<DialogSurface
// Mặc định Radix focus phần tử đầu tiên (nút X). Chuyển focus vào ô cần nhập:
onOpenAutoFocus={(e) => {
e.preventDefault();
nameRef.current?.select();
}}
>
<DialogTitle>Đổi tên tệp</DialogTitle>
<DialogContent>
<Field label="Tên mới">
<Input ref={nameRef} defaultValue="Báo cáo quý 3" />
</Field>
</DialogContent>
<DialogActions>
<DialogClose asChild>
<Button>Huỷ</Button>
</DialogClose>
<DialogClose asChild>
<Button appearance="primary">Lưu</Button>
</DialogClose>
</DialogActions>
</DialogSurface>
</Dialog>
);
}
{/* Đóng bằng Esc, nút X hay Huỷ → focus tự quay về nút "Đổi tên" */}| Tình huống | Việc cần làm |
|---|---|
| Mở Dialog / Drawer | Mặc định focus phần tử focus được đầu tiên. Dùng onOpenAutoFocus để chọn ô nhập chính. |
| Đóng overlay | Focus tự về trigger. Dùng onCloseAutoFocus nếu cần trả về nơi khác. |
| Trigger biến mất (xoá item vừa mở menu) | Tự chuyển focus tới item kế tiếp hoặc tiêu đề danh sách — đừng để focus rơi về <body>. |
| Điều hướng trang trong SPA | Đưa focus về tiêu đề h1 (tabIndex={-1}) hoặc vùng main của trang mới. |
| Nội dung tải thêm | Giữ focus tại chỗ; thông báo số mục mới qua live region thay vì kéo focus đi. |