The HTML dialog element has been around for quite a while, but only recently got a decent browser support, thanks to IE11 death and Safari updates. For those who need wider support range, Chrome team has a tiny polyfill available.
Let’s look at the examples of an accessible Dialog
component first to see what parts we need to “get inspired by”.
import * as Dialog from "@radix-ui/react-dialog";
export default () => {
const [open, setOpen] = React.useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<form
onSubmit={(event) => {
performOperation().then(() => setOpen(false));
event.preventDefault();
}}
>
{/** some inputs */}
<button type="submit">Submit</button>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};
You noticed I pulled in “Controlled” example with open
state provided by the consumer. I believe that in most cases the consumers of the dialog will be controlling when and how the dialog is closed.
Similarly, React Spectrum by Adobe example:
import {
ActionButton,
Button,
ButtonGroup,
Content,
Dialog,
DialogTrigger,
Divider,
Header,
Heading,
Text,
} from "@adobe/react-spectrum";
<DialogTrigger>
<ActionButton>Check connectivity</ActionButton>
{(close) => (
<Dialog>
<Heading>Internet Speed Test</Heading>
<Header>Connection status: Connected</Header>
<Divider />
<Content>
<Text>Start speed test?</Text>
</Content>
<ButtonGroup>
<Button variant="secondary" onPress={close}>
Cancel
</Button>
<Button variant="accent" onPress={close}>
Confirm
</Button>
</ButtonGroup>
</Dialog>
)}
</DialogTrigger>;
Here, the API is a little different. React Spectrum always pass
close
callback for the consumer of the component to control the internalopen
state of theDialog
.
Enter native dialog
#
Approaching the API naïvely, we could do something like this:
const Dialog = ({ open, children, ...props }) => {
return (
<dialog open={open} {...props}>
{children}
</dialog>
);
};
const App = () => {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Open dialog
</button>
<Dialog open={open}>
<form
onSubmit={(e) => {
perform().then(() => setOpen(false));
e.preventDefault();
}}
>
...
<button>OK</button>
</form>
</Dialog>
</>
);
};
By providing open
attribute to the <dialog>
, however, you are opting out from all the accessibility benefits <dialog>
can provide. No surprise, as MDN’s description of the property is:
The
open
property of theHTMLDialogElement
interface is a boolean value reflecting the open HTML attribute, indicating whether the<dialog>
is available for interaction. The property is now read only — it is possible to set the value to programmatically show or hide the dialog.
React sets open
attribute as the value changes, making the dialog
available for interaction. But that’s not really want we want. Let’s take another approach instead:
const Dialog = ({ open, hide, children, ...props }) => {
const dialogRef = useRef(null);
useEffect(() => {
if (open) {
dialogRef.current.showModal();
} else {
dialogRef.current.close();
}
}, [open]);
return (
<dialog
ref={dialogRef}
// hide is a `() => void` function that sets `open` state to `false`
onClose={hide}
onCancel={hide}
{...props}
>
{children}
</dialog>
);
};
This is more verbose, but with this simple change, your <dialog>
already handles all the accessibility requirements of the dialog window:
- captures focus within the dialog, making other page content “invisible”;
- applies backdrop, clicking on which will close the dialog;
- Esc press will close the dialog;
- focus returns to the trigger element after the dialog is closed;
Give better controls#
In the simple implementation above, you could see that the Dialog
now requires passing hide
method. This can also be used to provide a consistent close button across all of your application:
<dialog {...props}>
<button type="button" aria-label="Close the dialog">
X
</button>
...
</dialog>
The way you can leverage the fact that your consumers need to control the state of the dialog, is by providing a re-usable function of the controls state, to pass down to your Dialog
component:
// Creates controls
export function useDialogControls({ defaultOpen }) {
const [open, setOpen] = useState(defaultOpen);
return {
open,
show: () => setOpen(true),
hide: () => setOpen(false),
toggle: () => setOpen((open) => !open),
};
}
// Uses controls
export const Dialog = ({ controls, children, ...props }) => {
const dialogRef = useRef(null);
useEffect(() => {
if (controls.open) {
dialogRef.current?.showModal();
} else {
dialogRef.current?.close();
}
}, [controls.open]);
return (
<dialog
ref={dialogRef}
onClose={controls.hide}
onCancel={controls.hide}
{...props}
>
{children}
</dialog>
);
};
Now the consumers of the Dialog
can simply use useDialogControls
and pass the controls
down to the Dialog
:
const App = () => {
const controls = useDialogControls({ defaultOpen: false });
return (
<>
<button type="button" onClick={controls.show}>
Open dialog
</button>
<Dialog controls={controls}>
...
<button type="button" onClick={controls.hide}>
OK
</button>
</Dialog>
</>
);
};
This approach is used by the upcoming Ariakit - Dialog and I love it: it’s DRY, concise and extensible (Ariakit version is also more optimised for passing controls around without causing rerenders).
const App = () => {
const dialog = useDialogState();
return (
<>
<Button onClick={dialog.toggle}>Show modal</Button>
<Dialog state={dialog} className="dialog">
<DialogDismiss>OK</DialogDismiss>
</Dialog>
</>
);
};
useEffect
is ugly there#
…can’t we just call
{
hide: () => dialogRef.current.close(),
show: () => dialogRef.current.showModal()
}
?
Well, you could, by reversing the control:
export function useDialogControls({ defaultOpen }) {
const dialogRef = useRef(null);
const [open, setOpen] = useState(defaultOpen);
return {
dialogRef,
open,
show: () => {
setOpen(true);
dialogRef.current?.showModal();
},
hide: () => {
setOpen(false);
dialogRef.current?.close();
},
toggle: () => {
if (dialogRef.current?.open) {
dialogRef.current?.close();
setOpen(false);
} else {
dialogRef.current?.showModal();
setOpen(true);
}
},
};
}
And then use it as before:
const Dialog = ({ controls, children, ...props }) => {
return (
<dialog
ref={controls.dialogRef}
onClose={controls.hide}
onCancel={controls.hide}
{...props}
>
{children}
</dialog>
);
};
const App = () => {
const controls = useDialogControls({ defaultOpen: false });
return (
<>
<button type="button" onClick={controls.show}>
Open dialog
</button>
<Dialog controls={controls}>
{controls.Open ? <FormWithFetchForLatestData /> : null}
</Dialog>
</>
);
};
This might seem a bit more of an “inside out” approach, exposing dialogRef
onto the consumer of the dialog, but in the end, does it really matter?
Is that it?#
Technically yes, but also not. There’s a reason why RadixUI
and React Spectrum wrap the Dialog
into ContextProvider
and provide trigger Button
primitive. This allows connecting a trigger button to its dialog for screen readers at any depth.
All of them also provide a way to specify the description of the dialog for screen readers, like so:
<Dialog controls={controls}>
<Heading>Create new avatar</Heading>
</Dialog>
As both elements can read from parent’s context, <dialog>
can be connected to the Heading
text via aria-labelledby
and id
to improve the accessibility:
// output:
<dialog aria-labelledby="generated-unique-id">
<h2 id="generated-unique-id">Create new avatar</h2>
</dialog>
Which you can also expect consumer to care about instead:
const App = () => {
const controls = useDialogControls({ defaultOpen: false });
const heading = 'Create new avatar';
return (
<>
<button type="button" onClick={controls.show}>
Open dialog
</button>
<Dialog controls={controls} aria-label={heading}>
<h1>{heading}</h2>
</Dialog>
</>
);
};
I always suggest to go to the libraries I mentioned and run their examples with screen readers, like Voice Over on Mac, to see how richer the experience becomes with proper accessibility hints. Luckily, all the libraries I mentioned are open-sourced and can be served as a great inspiration for your own component API.