Skip to content

Dialog and Sheet

Edit on GitHub
@volvo-cars/css v2.3.0@volvo-cars/react-headless v0.24.7

All three modal types use the native <dialog> element and the useDialog hook from @volvo-cars/react-headless. The CSS class on the <dialog> determines the variant.

Dialog title

This is a large dialog. On small viewports it slides up as a bottom sheet. On medium viewports and above it appears centered with a scale transition.

Close it by pressing Escape, clicking the backdrop, tapping the close button, or clicking Done.

import { useDialog } from '@volvo-cars/react-headless';
import { IconButton } from '@volvo-cars/react-icons';
export function DialogBasic() {
const { dialogProps, showDialog, closeDialog } = useDialog();
return (
<>
<button type="button" className="button-filled" onClick={showDialog}>
Open dialog
</button>
<dialog className="dialog-large" {...dialogProps}>
<header slot="header">
<div slot="close">
<IconButton
icon="x"
bleed
aria-label="Close"
variant="clear"
onClick={closeDialog}
/>
</div>
<h2 className="font-16">Dialog title</h2>
</header>
<article slot="main" className="stack-text">
<p>
This is a large dialog. On small viewports it slides up as a bottom
sheet. On medium viewports and above it appears centered with a
scale transition.
</p>
<p>
Close it by pressing Escape, clicking the backdrop, tapping the
close button, or clicking Done.
</p>
</article>
<footer slot="footer">
<div className="button-group justify-end">
<button
type="button"
className="button-filled"
onClick={closeDialog}
>
Done
</button>
</div>
</footer>
</dialog>
</>
);
}

The default modal. Slides up as a bottom sheet on small viewports, then centers with a scale animation from md upward. Uses the dialog-large class.

The example above shows the typical large dialog with a sticky header, scrollable main content, and a footer with actions.

A compact, always-centered dialog for simple confirmations. Uses the dialog-small class. It stays centered at all viewport sizes and defaults to w-xs width.

Small dialogs typically don’t need a sticky header — place the title and actions directly in the main slot.

Are you sure you want to proceed?

All associated data will be permanently deleted and you will not be able to recover it.

import { useDialog } from '@volvo-cars/react-headless';
import { IconButton } from '@volvo-cars/react-icons';
export function DialogSmallExample() {
const { dialogProps, showDialog, closeDialog } = useDialog();
return (
<>
<button type="button" className="button-filled" onClick={showDialog}>
Open small dialog
</button>
<dialog className="dialog-small" {...dialogProps}>
<div slot="close">
<IconButton
bleed
icon="x"
aria-label="Close"
variant="clear"
onClick={closeDialog}
/>
</div>
<article className="stack-text" slot="main">
<h2 className="font-16 font-medium">
Are you sure you want to proceed?
</h2>
<p className="text-secondary">
All associated data will be permanently deleted and you will not be
able to recover it.
</p>
<div className="button-group">
<button
type="button"
className="button-filled"
data-color="subtle"
onClick={closeDialog}
>
Cancel
</button>
<button
type="button"
className="button-filled"
onClick={closeDialog}
>
Continue
</button>
</div>
</article>
</dialog>
</>
);
}

A side-anchored panel that slides in from the inline-end edge on md+ viewports. On small viewports it renders as a bottom sheet, identical to dialog-large. Uses the sheet class.

Equipment level

Ultra

Experience the highest equipment level for optimum comfort, advanced connectivity and outstanding Scandinavian style.

Includes premium sound system, head-up display, and full leather interior with ventilated seats.

import { useDialog } from '@volvo-cars/react-headless';
import { IconButton } from '@volvo-cars/react-icons';
export function SheetExample() {
const { dialogProps, showDialog, closeDialog } = useDialog();
return (
<>
<button type="button" className="button-filled" onClick={showDialog}>
Open sheet
</button>
<dialog className="sheet" {...dialogProps} data-anchor="end">
<header slot="header">
<div slot="close">
<IconButton
bleed
icon="x"
aria-label="Close"
variant="clear"
onClick={closeDialog}
/>
</div>
<h2 className="font-16">Equipment level</h2>
</header>
<article className="stack-text" slot="main">
<h3 className="heading-2">Ultra</h3>
<p>
Experience the highest equipment level for optimum comfort, advanced
connectivity and outstanding Scandinavian style.
</p>
<p>
Includes premium sound system, head-up display, and full leather
interior with ventilated seats.
</p>
</article>
<footer slot="footer">
<div className="button-group justify-end">
<button
type="button"
className="button-filled"
data-color="subtle"
onClick={closeDialog}
>
Cancel
</button>
<button
type="button"
className="button-filled"
onClick={closeDialog}
>
Compare
</button>
</div>
</footer>
</dialog>
</>
);
}

Sheets anchor to the inline-end side by default (data-anchor="end"). Set data-anchor="start" to anchor to the inline-start side instead. The slide animation direction adjusts automatically and is RTL-aware.

Navigation

Set data-anchor="start" to anchor the sheet to the inline-start side of the viewport. The default anchor is end.

import { useDialog } from '@volvo-cars/react-headless';
import { IconButton } from '@volvo-cars/react-icons';
export function SheetAnchorStart() {
const { dialogProps, showDialog, closeDialog } = useDialog();
return (
<>
<button type="button" className="button-filled" onClick={showDialog}>
Open start-anchored sheet
</button>
<dialog className="sheet" {...dialogProps} data-anchor="start">
<header slot="header">
<div slot="close">
<IconButton
icon="x"
bleed
aria-label="Close"
variant="clear"
onClick={closeDialog}
/>
</div>
<h2 className="font-16">Navigation</h2>
</header>
<article slot="main" className="stack-16">
<p>
Set <code>data-anchor=&quot;start&quot;</code> to anchor the sheet
to the inline-start side of the viewport. The default anchor is{' '}
<code>end</code>.
</p>
</article>
</dialog>
</>
);
}

All three variants accept width utility classes to override the default width: w-xs, w-sm, w-md, w-lg, w-xl.

Dialogs and sheets use a slot-based layout with slot attributes on direct children of the <dialog> element. All slots are optional.

SlotElementPurpose
header<header>Sticky header with a centered title
main<article>Scrollable content area
footer<footer>Sticky footer for action buttons
close<div>Floating close button that overlays content

Inside a header[slot="header"], place close and back buttons in nested div[slot="close"] and div[slot="back"] elements. The header grid auto-adjusts its layout when these are present.

Select wheels

The sticky header stays visible while scrolling and can include a back button on the start side and a close button on the end side.

The main slot scrolls independently, keeping the header and footer always visible.

import { useDialog } from '@volvo-cars/react-headless';
import { IconButton } from '@volvo-cars/react-icons';
export function DialogWithSlots() {
const { dialogProps, showDialog, closeDialog } = useDialog();
return (
<>
<button type="button" className="button-filled" onClick={showDialog}>
Open dialog
</button>
<dialog className="dialog-large" {...dialogProps}>
<header slot="header">
<div slot="back">
<IconButton
icon="chevron-back"
bleed
aria-label="Back"
variant="clear"
/>
</div>
<h2 className="font-16">Select wheels</h2>
<div slot="close">
<IconButton
icon="x"
bleed
aria-label="Close"
variant="clear"
onClick={closeDialog}
/>
</div>
</header>
<article slot="main" className="stack-text">
<p>
The sticky header stays visible while scrolling and can include a
back button on the start side and a close button on the end side.
</p>
<p>
The main slot scrolls independently, keeping the header and footer
always visible.
</p>
</article>
<footer slot="footer">
<div className="button-group justify-end">
<button
type="button"
className="button-filled"
data-color="subtle"
onClick={closeDialog}
>
Cancel
</button>
<button
type="button"
className="button-filled"
onClick={closeDialog}
>
Confirm
</button>
</div>
</footer>
</dialog>
</>
);
}

Children of [slot="main"] are constrained to the dialog’s padding by default. Add data-bleed="true" to a child to make it span the full width of the dialog, edge to edge.

Gallery

Content within the default padding.

18 inch 8 spoke aero wheels in silver metallic finish

More content after the full-bleed image.

import { useDialog } from '@volvo-cars/react-headless';
import { IconButton } from '@volvo-cars/react-icons';
export function DialogFullBleed() {
const { dialogProps, showDialog, closeDialog } = useDialog();
return (
<>
<button type="button" className="button-filled" onClick={showDialog}>
Open dialog
</button>
<dialog className="dialog-large" {...dialogProps}>
<header slot="header">
<div slot="close">
<IconButton
bleed
icon="x"
aria-label="Close"
variant="clear"
onClick={closeDialog}
/>
</div>
<h2 className="font-16">Gallery</h2>
</header>
<article slot="main" className="stack-24">
<p>Content within the default padding.</p>
<img
data-bleed="true"
src="/images/wheels.webp"
className="img"
alt="18 inch 8 spoke aero wheels in silver metallic finish"
/>
<p>More content after the full-bleed image.</p>
</article>
<footer slot="footer">
<div className="button-group justify-end">
<button
type="button"
className="button-filled"
onClick={closeDialog}
>
Done
</button>
</div>
</footer>
</dialog>
</>
);
}

Key consumer responsibilities from the WCAG audit:

  • Label the dialog — use aria-labelledby pointing to the dialog’s title, or aria-label if there is no visible title.
  • Focus managementuseDialog (and native showModal()) move focus into the dialog on open and restore it on close. Don’t override this.
  • No keyboard trapuseDialog handles Escape to close when dismissible is true (the default). For non-dismissible dialogs, always provide an explicit close action.
  • Backdrop dismissal — clicking outside the dialog closes it by default. Use dismissible: false or onBeforeDismiss to control this when closing would lose user work.

The useDialog hook manages open/close state, entry/exit animations, backdrop dismissal, Escape key handling, scroll reset, and focus restoration.

See the useDialog hook documentation for the full API reference including controlled mode, non-dismissible dialogs, and dismiss interception.