Tầng 3 là khung mà một sản phẩm Comitor chạy bên trong: sidebar, header, bộ chuyển workspace, app launcher, menu người dùng, menu off-canvas — một AppShell nhận dữ liệu của app một lần và trao cho mọi phần qua context.
Nó có entry riêng — @comitor/ui/shell — và mọi trang trong mục này đều import từ đó.
Vì sao nó nằm sau entry riêng
Gói được build với bundle: false — mỗi module nguồn một file ESM. Điều đó biến barrel thành một ranh giới cứng: nếu barrel chính export lại dù chỉ một file import next, mọi app dùng @comitor/ui sẽ phải cài Next, hoặc vỡ lúc resolve. Vì vậy khung — tầng duy nhất chạm tới next/link, next/navigation và next-themes — được export riêng, và cả hai peer đều khai là tuỳ chọn.
Hệ quả thực tế: một app Vite, một Storybook, một lượt chạy Vitest dùng được mọi primitive và mọi composite mà không có Next trong cây. Chỉ app nào cần khung mới trả giá cho khung.
terminal
# Tier 1 + Tier 2 need none of this — @comitor/ui alone runs on Vite, Storybook, Vitest.
pnpm add @comitor/ui
# Tier 3 pulls the two optional peers. Install them only if you import /shell.
pnpm add next next-themes
imports
// Tier 1 + Tier 2 — primitives, composites, cn, tokens
import { Button, DataTable, PageHeader, cn } from '@comitor/ui'
// Tier 3 — the frame. Separate entry, separate peers.
import { AppShell, ShellProvider, WorkspaceSwitcher, AppLauncher } from '@comitor/ui/shell'
Mọi component là Client Component
State, routing và xử lý phím đều nằm ở đây, nên mọi component trong entry đều có "use client" — trừ hai dấu hiệu thương hiệu, để Server Component render được chúng. Các module kiểu và hằng cố ý giữ thuần: một root layout server đọc thẳng CONTRAST_STORAGE_KEY từ một module như vậy, và export lại nó qua một file "use client" sẽ biến hằng thành proxy đọc ra undefined, không lỗi nào ở đâu cả.
Import mang đuôi .js
Shell đọc next/link.js, có cả đuôi file. next không công bố exports map, nên next/link trần resolve được dưới bundler nhưng throw ERR_MODULE_NOT_FOUND dưới Node ESM thuần — khiến entry vô hình với mọi smoke test trên CI. Hai cách cùng trỏ một file.
Không dùng Next?
Truyền pathname và LinkComponent thì shell không bao giờ gọi router của Next — vốn dĩ nó cũng không gọi useRouter(), vì hook đó throw khi không có App Router. Module vẫn import next lúc nạp, nên gói đó phải resolve được.
Hai trục điều hướng
Một người dùng Comitor thuộc nhiều workspace, và mỗi workspace bật nhiều sản phẩm. Hai thứ này vuông góc — đổi workspace không đổi sản phẩm đang dùng, đổi sản phẩm không đổi workspace đang ở — nên không gộp được vào một menu phẳng.
Trục 1 — Workspace
WorkspaceSwitcher
Đổi ngữ cảnh dữ liệu: Acme Corp ▸ Beta Ltd. Nó chỉ gọi onWorkspaceChange(id) — đổi workspace là đổi dữ liệu và quyền đi kèm, hệ quả quá lớn để khung tự quyết.
Trục 2 — App
AppLauncher
Đổi sản phẩm bên trong một workspace: Tasks ▸ Chat ▸ CRM ▸ HR. Sản phẩm workspace chưa mua vẫn nằm trong lưới kèm ổ khoá và gọi onUpsell(appId) thay vì điều hướng.
Tasks
Chat
CRM
HR
Acme Corp
đang ở đây
·
·
·
Beta Ltd
·
·
·
·
WorkspaceSwitcher đưa người dùng xuống theo cột; AppLauncher đưa đi theo hàng. Một menu phẳng chỉ diễn tả được một trong hai.
Không control nào nhận dữ liệu qua prop. Cả hai đọc context của ShellProvider, thứ mà AppShell đã mount sẵn — nên workspaces, apps, nav và user được truyền một lần, ở trên cùng. Tự ráp các phần bằng tay cũng vậy: bọc chúng trong một ShellProvider rồi đặt ở đâu tuỳ ý.
Đừng nhầm với năm trục hiển thị — sáng/tối, bảng tương phản, mật độ bố cục, cỡ chữ, bộ màu theme. Đó là một bộ riêng, cũng độc lập như vậy, có tài liệu ở Display Axes.
Khung, chạy thật
Một AppShell thật, đóng khung cao 460px. Cả hai trục chạy, sidebar thu gọn được, lưới launcher nhận phím mũi tên, ô CRM bị khoá gọi onUpsell, và ô tìm kiếm mở một bảng lệnh truyền vào qua commandPaletteSlot. Không gì điều hướng đi đâu: demo tự cung cấp LinkComponent.
Đổi workspace ở control góc trên bên trái thì app giữ nguyên. Mở launcher 3×3 trên header thì app đổi — kéo theo cả menu sidebar, vì menu thuộc về app chứ không thuộc về workspace. Hai trục là vì vậy.
callback gần nhất: —
Viewport đang dưới breakpoint md, nên sidebar hoàn toàn không được render — đó là hành vi thật. Nút hamburger mở MobileMenu thay vào.
Hai thứ bị tắt mà app sẽ để bật. enableShortcuts={false} và persistSidebarState={false}, để một trang tài liệu không chiếm ⌘K trên cả site hay ghi vào localStorage của người đọc. (collapseSidebarOnTablet tắt cũng vì lý do đó — cái hộp không phải viewport, nên mặc định cho tablet sẽ kích hoạt sai độ rộng.)
Mọi đường tới next-themes cũng tắt, và có ba đường: showThemeToggle của header, showThemeSubmenu của menu người dùng (mặc định là "mobile" — vẫn render, chỉ bị ẩn bằng md:hidden), và toggle riêng của MobileMenu. next-themes giữ một context cho mỗi document, nên bất kỳ đường nào trong ba cũng sẽ lật cả site này chứ không chỉ cái hộp. App thật để cả ba bật — và nhân tiện để ý cặp này: tắt toggle trên header trong app thì phải bật submenu của menu người dùng, không thì màn hình rộng mất mọi cách đổi theme.
Hai bố cục
Cùng component, cùng context, hai cách xếp container flex bên ngoài. Đổi giữa hai cách bằng các nút phía trên demo.
sidebar-first
Mặc định. Linear, Lark.
Sidebar chạy hết chiều cao cửa sổ; header nằm bên phải nó.
Bộ chuyển workspace là dải trên cùng của sidebar, nút thu gọn là dải dưới cùng.
Header không có nút bật tắt sidebar — sidebar có nút riêng.
Đọc như một sản phẩm có nhiều mục. Hợp nhất khi nav là xương sống của app.
header-first
Header rộng hết khổ ở trên.
Header trải hết cửa sổ; sidebar và nội dung nằm bên dưới.
Bộ chuyển workspace chuyển lên header, thành một khối 224px ở slot logo, và sidebar bỏ bản của nó.
Header có thêm nút bật tắt sidebar thay vào, vì sidebar không còn chỗ cho nút thu gọn.
Đọc như một công ty có nhiều sản phẩm. Hợp nhất khi header là phần thương hiệu dùng chung.
Những chỗ lật đó là mặc định, không phải luật — cái nào cũng là một prop đặt được trên headerProps hoặc sidebarProps. Thứ khung bảo vệ là bất biến phía sau: đúng một bộ chuyển workspace và đúng một control thu gọn trên màn hình. Hai nút giống hệt ở hai chỗ không làm giao diện phong phú hơn, nó chỉ khiến người dùng tự hỏi nút nào là thật. Mặc định được áp sau khi prop của app đã được trải, bằng ??, nên truyền headerProps mà không nhắc tới chúng thì không bị undefined đè mất.
Dưới breakpoint md thì lựa chọn này hết quan trọng: sidebar không render ở cả hai thiết lập, và cả hai bố cục thu về header cộng MobileMenu off-canvas. Menu đó đặt nav của app hiện tại trên danh sách app, ngược với cách xếp trên desktop — với tám hay mười sản phẩm trong hệ sinh thái, liệt kê chúng trước sẽ đẩy cái menu mà người dùng mở sheet ra để tìm xuống dưới màn hình.
Thứ gì vào root layout, thứ gì vào shell
Hai file, và cách chia giữa chúng không phải chuyện hình thức. Bốn provider trục hiển thị và provider ngôn ngữ thuộc về gốc, ngoàiAppShell; còn bản thân khung thuộc về layout của segment sở hữu dữ liệu của app.
app/layout.tsx
// app/layout.tsx — a SERVER component. No shell components here, only providers.
import { ComitorLocaleProvider } from '@comitor/ui'
import { ContrastProvider, DensityProvider, FontSizeProvider, ThemeProvider } from '@comitor/ui/shell'
import { getLocale } from 'next-intl/server'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({
// 'vietnamese' is not optional: without it the diacritics fall back to a system
// font and the text visibly changes shape mid-sentence.
subsets: ['latin', 'vietnamese'],
variable: '--font-inter',
})
export default async function RootLayout({ children }: { children: React.ReactNode }) {
// The user's own language, through next-intl — never a hard-coded 'vi'. Leave the
// provider out and every string in the package is English.
const locale = await getLocale()
return (
// suppressHydrationWarning is REQUIRED, not tidiness: all four anti-flash scripts
// mutate <html> before React hydrates, so the client markup never matches the server.
<html lang={locale} suppressHydrationWarning className={inter.variable}>
<body className="min-h-dvh bg-background font-sans text-foreground antialiased">
{/* All five are order-independent. The four display axes each write their own
attribute on <html> and none reads another; the language provider writes nothing
and reads none of them. What matters is that they sit at the ROOT, above AppShell. */}
<ComitorLocaleProvider locale={locale}>
<ThemeProvider>
<ContrastProvider>
<DensityProvider>
<FontSizeProvider>{children}</FontSizeProvider>
</DensityProvider>
</ContrastProvider>
</ThemeProvider>
</ComitorLocaleProvider>
</body>
</html>
)
}
app/(app)/layout.tsx
'use client'
// app/(app)/layout.tsx — a CLIENT component, and it has to be one: every callback
// below is a function, and AppDescriptor.icon / NavItem.icon are React components.
// Neither crosses the server → client boundary.
import { AppShell, SonnerToaster } from '@comitor/ui/shell'
import { useRouter } from 'next/navigation'
import type { ReactNode } from 'react'
import { CommandPaletteSlot } from '@/components/command-palette-slot'
import { NotificationsSlot } from '@/components/notifications-slot'
import { apps, nav, user, workspaces } from '@/lib/shell-data'
export default function AppLayout({ children }: { children: ReactNode }) {
const router = useRouter()
return (
<>
<AppShell
workspaces={workspaces}
currentWorkspaceId="acme"
apps={apps}
currentAppId="tasks"
nav={nav}
user={user}
// Switching workspace swaps the whole data context and the permissions with it,
// so the shell never decides it — it hands you the id and stops.
onWorkspaceChange={(id) => router.push(`/w/${id}`)}
onAppSelect={(app) => router.push(app.href)}
onUpsell={(appId) => router.push(`/billing?app=${appId}`)}
onLogout={() => router.push('/sign-out')}
// No `locale` prop: the ComitorLocaleProvider in the root layout already covers the
// frame, and the sign-in and error pages this layout never renders.
headerProps={{
// The package ships the bell button only; the panel behind it is app data.
notificationsSlot: <NotificationsSlot />,
userMenuProps: { settingsHref: '/settings' },
}}
// Rendered inside ShellProvider, so useShell() works in there.
commandPaletteSlot={<CommandPaletteSlot />}
>
{children}
</AppShell>
{/* OUTSIDE the frame. AppShell is h-dvh + overflow-hidden; a toaster inside it is
asking to be clipped. SonnerToaster is the next-themes-aware one — the plain
Toaster at '@comitor/ui' is for apps without next-themes. Both hand the
provider's language to toastPromise() — this one since 3.0.1. */}
<SonnerToaster />
</>
)
}
Năm chỗ dễ làm sai
Bốn provider đặt ở gốc, trên AppShell. Thứ tự lồng nhau không quan trọng — không provider nào biết các provider kia tồn tại, và mỗi cái chỉ ghi thuộc tính của riêng nó lên <html>.
Ngôn ngữ cũng vậy.ComitorLocaleProvider là provider thứ năm nhưng không phải trục thứ năm: nó không ghi gì lên <html>, không lưu gì vào storage và đặt ở đâu trong chồng đó cũng được. Không có nó thì gói nói tiếng Anh, một mặc định hợp lệ — nên một app tiếng Việt quên nó sẽ hiện “Cancel” giữa màn hình mà không lỗi nào báo. Prop locale của chính AppShell chỉ phủ khung, không phủ các trang đăng nhập và trang lỗi vốn không render khung. Xem thêm ở Đặt ngôn ngữ.
suppressHydrationWarning trên <html>. Cả bốn script chống nháy đều sửa thẻ đó trước khi React hydrate, nên markup phía client không bao giờ khớp markup phía server. Các provider tự render những script đó, trong <body> trước nội dung của app — React 19 ghi cảnh báo ở mọi lần render phía client nếu script nằm trong <head>, và vị trí trong body chặn lần vẽ y như vậy.
Layout của segment phải là Client Component. Mọi callback là hàm, và AppDescriptor.icon / NavItem.icon là React component. Không thứ nào qua được ranh giới server → client, nên bảng nav và bảng app phải được import từ bên trong file client.
Toaster đặt ngoài khung.AppShell là h-dvh overflow-hidden; toaster bên trong nó là tự chuốc lấy bị cắt. Mount SonnerToaster làm anh em của nó, một lần cho cả app.
Một tuỳ chọn thêm: searchParams
Chỉ cần khi hai hàng menu khác nhau ở query chứ không ở path. Truyền vào thì shell phân biệt được ?tab=general với ?tab=members; bỏ đi thì mọi hàng trên path đó sáng cùng lúc. Nó nhận một URLSearchParams, một chuỗi "?tab=x", hoặc object searchParams của một Server Component.
shell.tsx
'use client'
import { useSearchParams } from 'next/navigation'
import { AppShell } from '@comitor/ui/shell'
import { nav } from '@/lib/shell-data'
/**
* Only needed when menu rows differ by QUERY — /settings?tab=general vs
* /settings?tab=members. usePathname() never carries a query, so without this every
* row on /settings lights up at once.
*
* The shell deliberately does not call useSearchParams() itself: that hook forces a
* <Suspense> boundary on the page and pushes static builds to client rendering. That
* cost has to be the app's choice, not a side effect of importing the shell.
*/
export function Shell({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams()
return <AppShell nav={nav} searchParams={searchParams}>{children}</AppShell>
}
AppShell — prop của khung
Đây là các prop định hình khung. AppShellProps còn mở rộng ShellProviderProps — dữ liệu, các callback, labels, searchParams và các cờ trạng thái — tất cả có tài liệu ở App Shell.
Prop
Kiểu
Mặc định
Mô tả
layout
'sidebar-first' | 'header-first'
'sidebar-first'
Dựng khung nào trong hai khung. sidebar-first cho sidebar chạy hết chiều cao cửa sổ, bộ chuyển workspace ở trên cùng (Linear, Lark); header-first cho một header rộng hết khổ chạy ngang phía trên và chuyển bộ chuyển workspace lên đó.
showSidebar
boolean
true
Bỏ hẳn sidebar — cho sản phẩm chỉ có một khu vực, chỉ cần header. MobileMenu vẫn được render, nên màn hình hẹp vẫn có đường vào nav.
headerProps
AppHeaderProps
—
Chuyển tiếp cho AppHeader: title, breadcrumb, logo, searchSlot, notificationsSlot, userMenuProps, và các công tắc showSearch / showAppLauncher / showThemeToggle / showUserMenu. Hai giá trị được tính từ layout khi để undefined — xem bên dưới.
sidebarProps
SidebarProps
—
Chuyển tiếp cho Sidebar: slot header và footer, showWorkspaceSwitcher, showCollapseButton. Hai cái sau cũng lấy mặc định từ layout.
mobileMenuProps
MobileMenuProps
—
Chuyển tiếp cho MobileMenu, sheet off-canvas dưới breakpoint md. Tắt showAppLauncher của nó là bỏ con đường duy nhất tới danh sách app trên màn hình hẹp — launcher trên header bị ẩn dưới md.
commandPaletteSlot
ReactNode
—
Chỗ đặt bảng lệnh Cmd/Ctrl+K của chính app. Được render bên trong ShellProvider, nên component truyền vào gọi được useShell() và đọc commandPaletteOpen. Để trống thì ô tìm kiếm trên header cùng phím tắt ⌘K thành control chết — shell giữ trạng thái mở, không bao giờ giữ nội dung.
locale
string
—
Lối tắt, không phải chỗ nên đặt. Khi có giá trị (mã BCP 47: vi, en, en-GB…), AppShell bọc cây của nó trong một ComitorLocaleProvider với locale đó; không có thì nó đọc provider nào ở trên, và tiếng Anh khi không có provider nào. Ngôn ngữ vẫn thuộc về root layout: trang đăng nhập và trang lỗi không bao giờ render shell. Provider mà lối tắt dựng là một provider mới — không kế thừa messages hay dateFnsLocale từ provider bên ngoài — và đổi prop giữa có và không có giá trị sẽ đổi hình dạng cây, nên cả shell mount lại.
className
string
—
Class trên khung ngoài cùng, mặc định là h-dvh w-full overflow-hidden. Gộp qua cn(), nên chiều cao truyền vào đây thay thế mặc định chứ không giành nhau với nó.
contentClassName
string
—
Class trên phần tử <main>. main cố ý là relative: thiếu nó, các phần tử con định vị tuyệt đối (sr-only của Tailwind, input gốc ẩn của Radix) thoát khỏi vùng cắt overflow và kéo dài chiều cao cuộn của document — đo được 631px khoảng trống dưới một trang, 892px dưới trang khác.
Mọi thứ trong tầng này
Sáu trang dưới /shell, mỗi trang một phần của khung.
Mặc định shell nói tiếng Anh — kể cả các chuỗi chỉ trình đọc màn hình nghe thấy (aria-label, sr-only). Tiếng Việt có sẵn và được chọn một lần, bằng ComitorLocaleProvider trong root layout ở trên; mọi chuỗi trong khung đi theo nó, cũng như mọi component khác trong gói. Không fork, không bọc lại gì, và không cần prop nào trên shell — Đặt ngôn ngữ có chi tiết.
Đây là chuyện của cả tầng, không phải của từng component. Cả Tầng 3 có đúng một bộ nhãn — bảng shell của provider — và mọi phần đọc nó qua context của shell: sidebar, header, hai bộ chuyển, menu người dùng, sheet trên mobile. labels trên AppShell (hoặc ShellProvider) dành cho cách gọi riêng của app: nó được gộp từng khoá lên bảng đó, nên Partial<ShellLabels> là đủ, một khoá ghi đè gọn gàng, và mọi khoá bỏ trống vẫn ở ngôn ngữ của provider.
Khi một khoá vừa là chữ hiển thị vừa là tên truy cập, ghi đè cả hai
Nút thu gọn mang hai chuỗi như vậy, và chúng cố ý được tách ra. collapseSidebar là tên truy cập — aria-label và tooltip, được nghe ngoài ngữ cảnh, nên phải nói rõ nó thu gọn cái gì. collapseSidebarShort là chữ in bên trong một nút 240px, nơi ngữ cảnh đã rõ.
WCAG 2.5.3 Label in Name đòi chuỗi hiển thị phải nằm trong tên truy cập: Collapse ⊂ Collapse sidebar ✓. Giữ quan hệ đó trong mọi bản dịch, không thì lệnh giọng nói “click Collapse” không còn khớp với nút. Cùng quy tắc này bắt được lỗi phổ biến nhất trên trang song ngữ: ghi đè một nhãn tiếng Anh lên một chuỗi hiển thị tiếng Việt để lại một tên truy cập chẳng liên quan gì tới thứ trên màn hình.
ShellLabels có 34 khoá từ 3.7.0, bản thêm skipToContent cho skip link của khung; trước đó bộ khoá không đổi kể từ khi collapseSidebarShort được tách ra ở 0.5.0. Khoá nào cũng là chuỗi thường, trừ members là hàm của số thành viên: nó chèn một con số, thứ tự từ quanh con số đổi theo ngôn ngữ, và tiếng Anh cần “1 member” bên cạnh “24 members” — lựa chọn mà một chuỗi có placeholder không làm được. Bảng tiếng Anh là DEFAULT_SHELL_LABELS (bản sao của comitorMessagesEn.shell), bảng tiếng Việt là comitorMessagesVi.shell; shell không đọc trực tiếp bảng nào, chỉ đọc provider. Cách gọi riêng của app, giữ theo từng ngôn ngữ:
components/shell.tsx
'use client'
// Wording, not language: this product's tenants are companies. The language itself is
// set once, by ComitorLocaleProvider in the root layout — labels never has to carry it.
import type { ReactNode } from 'react'
import { type ComitorLanguage, useComitorLocale } from '@comitor/ui'
import { AppShell, type ShellLabels } from '@comitor/ui/shell'
// An override is written in ONE language, so an app that runs in two keeps one per
// language. Partial: the other 29 keys still come from the provider's table.
const COMPANY_WORDING: Record<ComitorLanguage, Partial<ShellLabels>> = {
en: {
workspace: 'Company',
workspaceSwitcherLabel: 'Switch company',
workspaceSearchPlaceholder: 'Search companies…',
workspaceEmpty: 'No companies found',
},
vi: {
workspace: 'Công ty',
workspaceSwitcherLabel: 'Đổi công ty',
workspaceSearchPlaceholder: 'Tìm công ty…',
workspaceEmpty: 'Không tìm thấy công ty',
},
}
export function Shell({ children }: { children: ReactNode }) {
// 'en' | 'vi' — the built-in table the provider picked for its locale.
const { language } = useComitorLocale()
return <AppShell labels={COMPANY_WORDING[language]}>{children}</AppShell>
}
// The same wording for the WHOLE app — ThemeToggle and NotificationsButton outside the
// frame included — goes on the provider's messages instead. Keep that object at module
// scope: a new one on every render re-renders everything that reads a string.
//
// const MESSAGES = { en: { shell: COMPANY_WORDING.en }, vi: { shell: COMPANY_WORDING.vi } }
// <ComitorLocaleProvider locale={locale} messages={MESSAGES[getComitorLanguage(locale)]}>
Ngôn ngữ thứ ba là cả một bảng trên messages của provider: trải comitorMessagesEn ra rồi dịch. Khai kiểu namespace shell của nó là ShellLabels đầy đủ chứ không phải Partial: khi gói thêm một khoá, object đó đỏ trong tsc thay vì một góc của khung lặng lẽ ở lại tiếng Anh.
Hai toggle có prop labels riêng, vì cả hai dùng được trên trang đăng nhập hay landing page không có ShellProvider phía trên. Mặc định của chúng lấy thẳng từ ComitorLocaleProvider — thêm một lý do để nó nằm ở gốc, nơi trang đăng nhập đó cũng nằm. Chỉ một trong hai thật sự nằm ngoài bộ: ContrastToggle có hợp đồng riêng, ContrastToggleLabels — hai khoá, label và description, lấy từ namespace contrast của provider chứ không phải shell. ThemeToggleLabels hoàn toàn không phải bộ thứ hai: nó là Pick<ShellLabels, …> trên bốn khoá theme ở trên — cùng các chuỗi đó, đọc từ provider khi toggle đứng một mình. Bên trong AppShell, header tự truyền labels đã gộp của shell cho toggle, nên một ghi đè labels trên khung cũng tới được nó.
Trợ năng
Khung dựng từ landmark thật: sidebar là một <aside> chứa một <nav> mang nhãn labels.navigationLabel, header là một <header>, và nội dung là một <main> duy nhất — nên điều hướng theo landmark tới được từng vùng mà không cần dựng thêm ARIA. Từ 3.7.0 khung còn mở đầu bằng một skip link tới <main> đó, để người dùng bàn phím không phải Tab qua cả sidebar trước.
Phím tắt là toàn cục nhưng nhường cho việc gõ: handler bỏ qua khi đang ở input, textarea, select và mọi contenteditable trước khi xét tới phím. Escape là ngoại lệ và luôn đóng bảng lệnh, menu mobile và launcher. ⌘/Ctrl+K mở bảng lệnh, ⌘/Ctrl+Shift+K mở launcher, ⌘/Ctrl+B thu gọn sidebar, và Alt+1…9 nhảy tới app thứ n được cấp quyền — đọc từ event.code, không phải event.key, vì Alt+1 trên macOS ra “¡”. Cả bộ tắt được bằng một prop: enableShortcuts={false}.
Đổi route là đóng mọi lớp phủ đang mở, nên focus bàn phím không bao giờ bị bỏ lại trong một panel đang mô tả trang vừa rời đi.
Mọi chuỗi khung nói ra đều theo ngôn ngữ của provider, kể cả chuỗi chỉ dành cho trình đọc màn hình, và chuỗi nào cũng ghi đè được qua ShellLabels. Đó là lý do ngôn ngữ được đặt một lần chứ không từng chuỗi một: một trang dịch những gì nhìn thấy và để phần còn lại ở mặc định sẽ trông hoàn hảo trên ảnh chụp màn hình, nhưng nói một ngôn ngữ khác với những người không nhìn thấy nó.
Trục cỡ chữ không bao giờ ghi một cỡ tuyệt đối lên <html>. Mặc định của nó gỡ hẳn thuộc tính, còn sm / lg là phần trăm của cỡ mà trình duyệt đang đặt (87,5% / 112,5%). Khai một cỡ cố định sẽ xoá thiết lập riêng của người đọc — vi phạm WCAG 1.4.4, từ bên trong một tính năng trợ năng.
Ô app bị khoá vẫn nằm trong thứ tự Tab và vẫn bấm được, nên không được miễn luật tương phản: nó không bị làm mờ bằng opacity, và nói “bị khoá” bằng nền icon xám, badge ổ khoá và tên truy cập chứ không chỉ bằng màu hay độ mờ.
Menu mobile là một sheet của Radix có SheetTitle thật (ẩn khỏi mắt, mang labels.navigationLabel), nên nó tự thông báo khi mở và giữ focus trong lúc đang mở.