Platform
Announce a change, follow the page's direction, and adapt keyboard shortcuts to the user's platform.
Use announce to send messages to screen readers, isRTL to read text direction, and the shortcut helpers to match and label keyboard shortcuts for the user's platform.
These helpers are available from @ui-sdk/core. You can use them inside a primitive or on their own.
Announcements
Say you've built a copy button. Once the copy succeeds, use announce to let a screen reader know:
import { announce } from "@ui-sdk/core"
async function copyText(text: string) {
await navigator.clipboard.writeText(text)
announce("Copied")
}announce writes to a shared live region. By default, it asks the screen reader to wait for a pause before reading the message. Try it with a screen reader:
import * as React from "react"
import { announce, definePrimitive } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { CheckIcon, CopyIcon } from "lucide-react"
type CopyOptions = {
text: string
}
type CopyState = { text: string; copied: boolean }
const copy = definePrimitive({
name: "copy",
options: { text: "string" },
state: (options: CopyOptions): CopyState => ({
text: options.text,
copied: false,
}),
sync: (options) => ({ text: options.text }),
api: (context) => {
const { store } = context
let timer: ReturnType<typeof setTimeout> | undefined
context.onDestroy(() => clearTimeout(timer))
return {
async copy() {
await navigator.clipboard?.writeText(store.get().text).catch(() => {})
announce("Copied")
store.set({ copied: true })
clearTimeout(timer)
timer = setTimeout(() => store.set({ copied: false }), 1500)
},
}
},
slots: (slot) => ({
root: slot<{}, { copied: boolean }>({
state: (state) => ({ copied: state.copied }),
attrs: (state) => ({
type: "button",
"data-copied": state.copied ? "" : undefined,
}),
press: ({ api }) => api.copy(),
}),
}),
})
const parts = createParts(copy, { root: "button" })
const CopyButton = parts.Root
function Example() {
return (
<div data-slot="announce-command">
<span>npm add @ui-sdk/core</span>
<CopyButton
data-size="sm"
text="npm add @ui-sdk/core"
aria-label="Copy command"
>
{({ copied }) => (
<>
{copied ? (
<CheckIcon aria-hidden="true" />
) : (
<CopyIcon aria-hidden="true" />
)}
{copied ? "Copied" : "Copy"}
</>
)}
</CopyButton>
</div>
)
}
export { copy, CopyButton, Example, type CopyOptions, type CopyState }This is useful for results that aren't otherwise announced, like a list finishing a sort or a file being copied. Call it after the action succeeds, in an event handler or bind. Keep attrs and state free of side effects.
Urgent Announcements
For something that needs attention now, use priority: "assertive":
announce("Enter an email address", { priority: "assertive" })This asks the screen reader to interrupt its current message. Use it sparingly; most updates can wait.
Some screen readers need the live region to exist before its text changes. If a component will announce soon after it appears, prepare the regions in its root's bind:
import { prepareAnnouncements } from "@ui-sdk/core"
root: slot({
bind: (el) => prepareAnnouncements(el.ownerDocument),
}),announce uses the current document by default. For an element inside a frame, pass its document too: announce("Copied", { doc: el.ownerDocument }).
Direction
In a left-to-right pager, Right goes forward. In a right-to-left pager, Left does. Use isRTL to read an element's computed direction:
import { isRTL } from "@ui-sdk/core"
function isForwardKey(event: KeyboardEvent, el: HTMLElement) {
const forward = isRTL(el) ? "ArrowLeft" : "ArrowRight"
return event.key === forward
}Call this from your key handler so it follows changes to the page's direction. Focus the pager below and try the arrow keys, then turn on Right to left:
import * as React from "react"
import { definePrimitive, isRTL } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type PagerState = { page: number }
const pager = definePrimitive({
name: "pager",
state: () => ({ page: 1 }),
api: (context) => {
const go = (by: number) =>
context.store.set({
page: Math.min(5, Math.max(1, context.store.get().page + by)),
})
return {
keydown(event: KeyboardEvent, el: HTMLElement) {
const forward = isRTL(el) ? "ArrowLeft" : "ArrowRight"
const back = isRTL(el) ? "ArrowRight" : "ArrowLeft"
if (event.key === forward) {
go(1)
} else if (event.key === back) {
go(-1)
} else {
return
}
event.preventDefault()
},
}
},
slots: (slot) => ({
root: slot<{}, PagerState>({
state: (state) => ({ page: state.page }),
attrs: (state) => ({
role: "spinbutton",
tabindex: "0",
"aria-valuemin": "1",
"aria-valuemax": "5",
"aria-valuenow": String(state.page),
}),
on: { keydown: (event, { el, api }) => el && api.keydown(event, el) },
}),
}),
})
const parts = createParts(pager, { root: "div" })
const Pager = parts.Root
function Example() {
const [rtl, setRtl] = React.useState(false)
return (
<div data-slot="stack">
<div data-slot="controls">
<label data-slot="label">
<input
type="checkbox"
checked={rtl}
onChange={(event) => setRtl(event.target.checked)}
/>
Right to left
</label>
</div>
<Pager dir={rtl ? "rtl" : "ltr"} aria-label="Page">
{({ page }) => (
<>
<span data-slot="hint">Page {page} of 5</span>
<span aria-hidden data-slot="pager-pages">
{Array.from({ length: 5 }, (_, index) => index + 1).map(
(number) => (
<span
key={number}
data-slot="pager-page"
data-active={number === page ? "" : undefined}
>
{number}
</span>
)
)}
</span>
</>
)}
</Pager>
<p data-slot="hint">
Next page: {rtl ? "← Left arrow" : "→ Right arrow"}
</p>
</div>
)
}
export { Example, pager, Pager, type PagerState }Roving Focus already handles this for Left and Right. For language rather than direction, see The Page's Language. To follow the light or dark scheme people choose or their browser prefers, see Preferences.
Shortcuts
Use mod for Command on Apple platforms and Control elsewhere. Inside a keydown handler, match the shortcut before calling your action:
import { matchesShortcut } from "@ui-sdk/core"
if (matchesShortcut(event, "mod+b")) {
event.preventDefault()
toggleBold()
}Here, toggleBold is your own function. matchesShortcut only checks the keys; the handler prevents the browser's default action and runs yours.
The same shortcut string gives you both a visible label and an aria-keyshortcuts value:
import { ariaKeyshortcuts, formatShortcut } from "@ui-sdk/core"
formatShortcut("mod+b") // Displays “⌘B” on Apple platforms, “Ctrl+B” elsewhere.
ariaKeyshortcuts("mod+b") // "Meta+B" or "Control+B".These labels depend on the user's platform. In a primitive, set them in bind so the server and initial client render stay consistent:
keys: slot({
bind: (el) => {
el.textContent = formatShortcut("mod+b")
},
}),Try the shortcut while typing in the note:
import * as React from "react"
import {
ariaKeyshortcuts,
definePrimitive,
formatShortcut,
matchesShortcut,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type BoldState = { bold: boolean }
const shortcut = "mod+b"
const boldText = definePrimitive({
name: "bold-text",
state: (): BoldState => ({ bold: false }),
api: (context) => ({
toggle: () => context.store.set({ bold: !context.store.get().bold }),
}),
slots: (slot) => ({
root: slot({
on: {
keydown: (event, { api }) => {
if (!matchesShortcut(event, shortcut)) {
return
}
event.preventDefault()
api.toggle()
},
},
}),
toggle: slot({
attrs: (state) => ({
type: "button",
"aria-pressed": String(state.bold),
}),
press: ({ api }) => api.toggle(),
bind: (el) =>
el.setAttribute("aria-keyshortcuts", ariaKeyshortcuts(shortcut)),
}),
keys: slot({
attrs: () => ({ "aria-hidden": "true" }),
bind: (el) => {
el.textContent = formatShortcut(shortcut)
},
}),
text: slot<{}, BoldState>({ state: (state) => ({ bold: state.bold }) }),
}),
})
const parts = createParts(boldText, {
root: "div",
toggle: "button",
keys: "kbd",
text: "textarea",
})
function Example() {
return (
<parts.Root>
<parts.Toggle>
Bold
<parts.Keys />
</parts.Toggle>
<parts.Text
aria-label="Note"
rows={2}
defaultValue="Press the shortcut while typing."
/>
</parts.Root>
)
}
export { boldText, Example, type BoldState }formatShortcut keeps the label in left-to-right order, even on a right-to-left page. For tests, import setPlatform from @ui-sdk/core/testing and call setPlatform("apple") or setPlatform("other").
Layers and Positioning
Building a popup? Placing a Popup introduces position. Layers of Your Own covers bindLayer, the top layer, and keeping an element usable over a modal.
API Reference
| Helper | Use it to… |
|---|---|
announce(text, options?) | Announce a result, with optional priority and doc. |
prepareAnnouncements(doc) | Create the live regions before the first announcement. |
isRTL(el) | Read an element's text direction. |
matchesShortcut(event, shortcut) | Check whether a key press matches a shortcut. |
formatShortcut(shortcut) | Show the shortcut using the platform's symbols or names. |
ariaKeyshortcuts(shortcut) | Generate the value for aria-keyshortcuts. |