Screen reader accessibility
Make custom shapes describe themselves to screen readers, and announce messages from your own UI.
import { useState } from 'react'
import {
BaseBoxShapeUtil,
HTMLContainer,
RecordProps,
T,
TLComponents,
Tldraw,
TldrawUiButton,
TLShape,
TLUiOverrides,
useA11y,
useEditor,
} from 'tldraw'
import 'tldraw/tldraw.css'
import './screen-reader-accessibility.css'
const CARD_SHAPE_TYPE = 'note-card'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[CARD_SHAPE_TYPE]: { w: number; h: number; title: string; description: string }
}
}
type CardShape = TLShape<typeof CARD_SHAPE_TYPE>
export class CardShapeUtil extends BaseBoxShapeUtil<CardShape> {
static override type = CARD_SHAPE_TYPE
static override props: RecordProps<CardShape> = {
w: T.number,
h: T.number,
title: T.string,
description: T.string,
}
getDefaultProps(): CardShape['props'] {
return {
w: 300,
h: 180,
title: 'Untitled',
description: '',
}
}
// [1]
override getAriaDescriptor(shape: CardShape): string | undefined {
const { title, description } = shape.props
if (description) {
return `${title} - ${description}`
}
return title
}
// [2]
override getText(shape: CardShape): string | undefined {
return `${shape.props.title}\n${shape.props.description}`
}
component(shape: CardShape) {
const { title, description } = shape.props
return (
<HTMLContainer className="card-shape">
<div className="card-shape-title">{title}</div>
{description && <div className="card-shape-description">{description}</div>}
</HTMLContainer>
)
}
getIndicatorPath(shape: CardShape) {
const path = new Path2D()
path.rect(0, 0, shape.props.w, shape.props.h)
return path
}
}
const customShapes = [CardShapeUtil]
// [3]
const overrides: TLUiOverrides = {
translations: {
en: { [`tool.${CARD_SHAPE_TYPE}`]: 'Note card' },
},
}
function CustomAnnouncementPanel() {
const editor = useEditor()
// [4]
const a11y = useA11y()
const [isEnabled, setIsEnabled] = useState(false)
// [5]
const handleActionConfirmation = () => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) {
a11y.announce({
msg: `Action completed for ${selectedShapes.length} shape${selectedShapes.length === 1 ? '' : 's'}`,
priority: 'polite',
})
} else {
a11y.announce({
msg: 'No shapes selected',
priority: 'polite',
})
}
}
// [6]
const handleValidation = () => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length === 0) {
a11y.announce({
msg: 'Validation failed: No shapes selected',
priority: 'assertive',
})
} else {
const hasText = selectedShapes.some((shape) => {
const util = editor.getShapeUtil(shape)
return util.getText(shape)
})
if (!hasText) {
a11y.announce({
msg: 'Validation failed: Selected shapes must contain text',
priority: 'assertive',
})
} else {
a11y.announce({
msg: 'Validation passed',
priority: 'polite',
})
}
}
}
// [7]
const handleToggle = () => {
const newState = !isEnabled
setIsEnabled(newState)
a11y.announce({
msg: `Feature ${newState ? 'enabled' : 'disabled'}`,
priority: 'polite',
})
}
return (
<div className="tlui-menu announcement-panel">
<TldrawUiButton type="normal" onClick={handleActionConfirmation}>
Perform action
</TldrawUiButton>
<TldrawUiButton type="normal" onClick={handleValidation}>
Validate selection
</TldrawUiButton>
<TldrawUiButton type="normal" onClick={handleToggle}>
{isEnabled ? 'Disable' : 'Enable'} feature
</TldrawUiButton>
</div>
)
}
const components: TLComponents = {
TopPanel: CustomAnnouncementPanel,
}
export default function ScreenReaderAccessibilityExample() {
return (
<div className="tldraw__editor">
<Tldraw
shapeUtils={customShapes}
overrides={overrides}
components={components}
onMount={(editor) => {
// [8]
editor.createShape({
type: CARD_SHAPE_TYPE,
x: 100,
y: 100,
props: {
title: 'Meeting Notes',
description: 'Discussed Q4 planning and team goals',
},
})
editor.createShape({
type: CARD_SHAPE_TYPE,
x: 450,
y: 100,
props: {
title: 'Project Ideas',
description: 'Brainstorming session for new features',
},
})
editor.createShape({
type: CARD_SHAPE_TYPE,
x: 100,
y: 320,
props: {
title: 'Action Items',
description: 'Follow up with design team by Friday',
},
})
}}
/>
</div>
)
}
/*
This example shows two sides of screen reader support: making a custom shape
describe itself when selected, and announcing your own messages from custom UI.
[1]
`getAriaDescriptor()` returns the text announced when the shape is selected.
It's the place for an alt-text-style description that may differ from what's
visibly rendered. If it returns nothing, the announcer falls back to
`getText()`.
[2]
`getText()` returns the shape's visible text. The editor uses it for text
extraction and search, and the announcer uses it as the fallback described in
[1]. Returning it here also lets the "Validate selection" button below check
whether the selected shapes have any text at all.
[3]
The selection announcement ends with the shape's type, looked up as
`tool.<type>` in the UI translations. Without an override a custom shape is
announced by its raw key ("tool.note-card"), so we add a translation for it.
[4]
`useA11y()` returns the accessibility context. It must be called from a
component rendered inside `<Tldraw />`, such as this `TopPanel` component.
[5]
Polite announcements queue behind whatever the screen reader is currently
saying. Use them for confirmations and status updates.
[6]
Assertive announcements interrupt the current speech. Reserve them for
messages the user must hear now, like validation errors.
[7]
Announce state changes when the visible change alone (here, the button label
flipping) wouldn't be noticed by a screen reader user.
[8]
Create three sample cards. Select one to hear the `getAriaDescriptor()`
text, followed by the shape type and its index in reading order ("1 of 3").
The announcements render into an off-screen ARIA live region owned by the
default `A11y` component, so nothing visible changes.
*/
When a shape is selected, tldraw announces it through an ARIA live region. The text comes from the shape util: getAriaDescriptor() first, falling back to getText(), followed by the shape type and its position in reading order. The card shape here overrides both methods, and a translations override gives the custom type a readable name (tool.note-card) for that announcement.
The top panel shows the other half: useA11y() returns an announce() function you can call from any component inside <Tldraw />. Use priority: 'polite' for confirmations and status updates that can wait, and priority: 'assertive' for errors that should interrupt. Try selecting a card with a screen reader running, then press the buttons.