Modal
A window overlaid on either the primary window or another dialog window. Modal content is placed in the top layer, rendering the underneath content as inert / non-interactive.
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Modal, ModalTitle, ModalDescription, Label } from '@qwik-ui/headless';
import styles from '../snippets/modal.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(false);
return (
<div class="modal-container">
<button class="modal-trigger" onClick$={() => (isOpen.value = true)}>
Open Modal
</button>
<Modal class="modal" bind:show={isOpen}>
<ModalTitle>Edit Profile</ModalTitle>
<ModalDescription>
You can update your profile here. Hit the save button when finished.
</ModalDescription>
<Label>
Name
<input type="text" placeholder="John Doe" />
</Label>
<Label>
Email
<input type="text" placeholder="johndoe@gmail.com" />
</Label>
<footer>
<button onClick$={() => (isOpen.value = false)}>Cancel</button>
<button onClick$={() => (isOpen.value = false)}>Save Changes</button>
</footer>
</Modal>
</div>
);
});
The modal makes use of the HTML dialog
element, which is supported in every major browser.
Modals are used when an important choice needs to be made in the application. The rest of the content isn't interactive until a certain action has been performed.
✨ Features
- Follows the WAI ARIA Dialog & Alertdialog design patterns.
- Managed focus order
- Scroll locking
- Closes on escape
- Toggle backdrop dismiss
- Animation support
- Transition support
- Backdrop animations
Building Blocks
import { component$, useSignal } from '@builder.io/qwik';
import { Modal, ModalDescription, ModalTitle } from '@qwik-ui/headless';
export default component$(() => {
const isOpen = useSignal(false);
return (
<Modal bind:show={isOpen}>
<ModalTitle>Accessible Name</ModalTitle>
<ModalDescription>Optional Description</ModalDescription>
{/* other content */}
</Modal>
);
});
🎨 Anatomy
Component | Description |
Modal | The root component that contains the dialog element. |
ModalTitle | An accessible name for the modal. |
ModalDescription | An optional accessible description for the modal. |
Dialog extendability
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Modal, ModalTitle, ModalDescription, Label } from '@qwik-ui/headless';
import styles from '../snippets/modal.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(false);
return (
<div class="modal-container">
<button class="modal-trigger" onClick$={() => (isOpen.value = true)}>
Open Modal
</button>
<Modal class="modal" bind:show={isOpen}>
<ModalTitle>Edit Profile</ModalTitle>
<ModalDescription>
You can update your profile here. Hit the save button when finished.
</ModalDescription>
<Label>
Name
<input type="text" placeholder="John Doe" />
</Label>
<Label>
Email
<input type="text" placeholder="johndoe@gmail.com" />
</Label>
<footer>
<button onClick$={() => (isOpen.value = false)}>Cancel</button>
<button onClick$={() => (isOpen.value = false)}>Save Changes</button>
</footer>
</Modal>
</div>
);
});
The Qwik UI Modal component is built on top of the dialog
element. To see additional capabilities of the dialog, take a look at the offical MDN page.
Our goal is to fill existing gaps, ensuring the modal is a11y compliant and enriched with HTML spec features, reducing reliance on Qwik UI maintainers.
Attribute example
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Modal, ModalTitle, ModalDescription, Label } from '@qwik-ui/headless';
import styles from '../snippets/modal.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(false);
return (
<div class="modal-container">
<button class="modal-trigger" onClick$={() => (isOpen.value = true)}>
Open Modal
</button>
<Modal class="modal" bind:show={isOpen}>
<ModalTitle>Edit Profile</ModalTitle>
<ModalDescription>
You can update your profile here. Hit the save button when finished.
</ModalDescription>
<Label>
Name
<input type="text" placeholder="John Doe" />
</Label>
<Label>
Email
<input autofocus type="text" placeholder="johndoe@gmail.com" />
</Label>
<footer>
<button onClick$={() => (isOpen.value = false)}>Cancel</button>
<button onClick$={() => (isOpen.value = false)}>Save Changes</button>
</footer>
</Modal>
</div>
);
});
Above is an example of the autofocus
attribute, used to focus the 2nd input when the dialog is opened.
Open or close the modal
To open or close the modal, we can use bind:show
, a custom signal bind that leverages the bind syntax in Qwik.
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Modal, ModalTitle, ModalDescription, Label } from '@qwik-ui/headless';
import styles from '../snippets/modal.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(false);
return (
<div class="modal-container">
<button class="modal-trigger" onClick$={() => (isOpen.value = true)}>
Open Modal
</button>
<Modal class="modal" bind:show={isOpen}>
<ModalTitle>Edit Profile</ModalTitle>
<ModalDescription>
You can update your profile here. Hit the save button when finished.
</ModalDescription>
<Label>
Name
<input type="text" placeholder="John Doe" />
</Label>
<Label>
Email
<input type="text" placeholder="johndoe@gmail.com" />
</Label>
<footer>
<button onClick$={() => (isOpen.value = false)}>Cancel</button>
<button onClick$={() => (isOpen.value = false)}>Save Changes</button>
</footer>
</Modal>
</div>
);
});
showSig
is a signal that we pass into our Modal component to customize when the modal can be open or closed.
The example above opens the modal when the Open Modal button is clicked and closes it when a button inside the modal is clicked.
Custom signal binds are like a remote control for components, controlling states like opening or closing a modal.
The Top Layer
In Chrome and Edge, a top layer
UI button is shown in the dev tools. This means the element content is placed above any other content on the page, preventing any overflow or style issues.
The top layer is used in Qwik UI whenever a modal or popover is used in supported browsers. Popovers can be applied to any semantic element, modals in Qwik UI are tied to the dialog
element.
Backdrops
To add a modal backdrop, the ::backdrop
pseudo element can be utilized. Backdrops are right underneath top layer elements.
By default, the dialog
element comes with a subtle backdrop, below is a snippet customizing the backdrop background along with the backdrop filter css property.
.modal-backdrop::backdrop {
/* changing background */
background: rgba(0, 0, 0, 0.4);
/* providing multiple filters */
backdrop-filter: grayscale(90%) blur(10px);
}
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Modal, ModalTitle, ModalDescription, Label } from '@qwik-ui/headless';
import styles from '../snippets/modal.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(false);
return (
<div class="modal-container">
<button class="modal-trigger" onClick$={() => (isOpen.value = true)}>
Open Modal
</button>
<Modal class="modal modal-backdrop" bind:show={isOpen}>
<ModalTitle>Edit Profile</ModalTitle>
<ModalDescription>
You can update your profile here. Hit the save button when finished.
</ModalDescription>
<Label>
Name
<input type="text" placeholder="John Doe" />
</Label>
<Label>
Email
<input type="text" placeholder="johndoe@gmail.com" />
</Label>
<footer>
<button onClick$={() => (isOpen.value = false)}>Cancel</button>
<button onClick$={() => (isOpen.value = false)}>Save Changes</button>
</footer>
</Modal>
</div>
);
});
Dismissing Modals via Backdrop
Modals in Qwik UI can be dismissed by default when the backdrop is clicked. However, this behavior can be modified using the closeOnBackdropClick
property.
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Modal, ModalTitle, ModalDescription, Label } from '@qwik-ui/headless';
import styles from '../snippets/modal.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(false);
return (
<div class="modal-container">
<button class="modal-trigger" onClick$={() => (isOpen.value = true)}>
Open Modal
</button>
<Modal closeOnBackdropClick={false} class="modal" bind:show={isOpen}>
<ModalTitle>Edit Profile</ModalTitle>
<ModalDescription>
You can update your profile here. Hit the save button when finished.
</ModalDescription>
<Label>
Name
<input type="text" placeholder="John Doe" />
</Label>
<Label>
Email
<input type="text" placeholder="johndoe@gmail.com" />
</Label>
<footer>
<button onClick$={() => (isOpen.value = false)}>Cancel</button>
<button onClick$={() => (isOpen.value = false)}>Save Changes</button>
</footer>
</Modal>
</div>
);
});
The example above demonstrates a modal where the closeOnBackdropClick
property is set to false. As a result, clicking on the backdrop does not dismiss the modal.
Alertdialog
An alert dialog is a modal that interrupts user workflow to convey critical information and obtain a response. It's typically used for confirmations, error messages, or destructive actions.
import { component$, useSignal } from '@builder.io/qwik';
import { Modal, ModalTitle, ModalDescription } from '@qwik-ui/headless';
export default component$(() => {
const showSig = useSignal(false);
return (
<>
<button class="modal-trigger" onClick$={() => (showSig.value = true)}>
Deactivate
</button>
<Modal alert class="modal" bind:show={showSig}>
<ModalTitle>Deactive Account</ModalTitle>
<ModalDescription>
Are you sure you want to deactivate your account?
</ModalDescription>
<footer>
<button onClick$={() => (showSig.value = false)}>Cancel</button>
<button onClick$={() => (showSig.value = false)}>Delete</button>
</footer>
<button class="modal-close" onClick$={() => (showSig.value = false)}>
+
</button>
</Modal>
</>
);
});
By adding the alert
prop to our component, we adhere to the WAI ARIA Alertdialog specification, enabling assistive technologies to distinguish alert dialogs for special handling.
Alertdialogs do not allow the backdrop to be closed when clicked, regardless of whether the closeOnBackdropClick
prop was set to true.
Focus Locking in Modals
Qwik UI incorporates a feature known as focus locking, which confines the focus within the modal component when it is open.
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Modal, ModalTitle, ModalDescription, Label } from '@qwik-ui/headless';
import styles from '../snippets/modal.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(false);
return (
<div class="modal-container">
<button class="modal-trigger" onClick$={() => (isOpen.value = true)}>
Open Modal
</button>
<Modal class="modal" bind:show={isOpen}>
<ModalTitle>Edit Profile</ModalTitle>
<ModalDescription>
You can update your profile here. Hit the save button when finished.
</ModalDescription>
<Label>
Name
<input type="text" placeholder="John Doe" />
</Label>
<Label>
Email
<input type="text" placeholder="johndoe@gmail.com" />
</Label>
<footer>
<button onClick$={() => (isOpen.value = false)}>Cancel</button>
<button onClick$={() => (isOpen.value = false)}>Save Changes</button>
</footer>
</Modal>
</div>
);
});
This means that if a user is navigating through the modal using the Tab key, reaching the last focusable element and pressing Tab again will cycle the focus back to the first focusable element within the modal.
Stripped Styles
As a headless library, we intentionally try not to add any styles to the components.
However, because Qwik UI builds on top of native solutions when they are well-supported, feasible, and performant, some of the widgets may inclue browser user-agent styles.
These styles can be unintuitive tricky to debug. Which has been the case with Qwik UI's own docs site. As a result, we have stripped these styles from the Modal component.
@layer qwik-ui {
/* browsers automatically set an interesting max-width and max-height for dialogs
https://twitter.com/t3dotgg/status/1774350919133691936
*/
dialog:modal {
max-width: unset;
max-height: unset;
}
}
While in most cases, this would be up to a consumer's CSS reset to solve, in this case we are stripping the max-width and max-height styles on the dialog element under the hood.
This is done in a separate layer so that styles are easily overridable in consumer facing applications.
Animations
Animating things to display none has historically been a significant challenge on the web. This is because display none is a discrete
property, and is unanimatable.
Our current approach
Qwik UI automatically detects any animation
or transition
declarations under the hood and waits for them to finish before closing the modal. If there is no animation, then it will close normally.
Adding a transition
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Modal, ModalTitle, ModalDescription, Label } from '@qwik-ui/headless';
import styles from '../snippets/animation.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(false);
return (
<div class="modal-container">
<button class="modal-trigger" onClick$={() => (isOpen.value = true)}>
Open Modal
</button>
<Modal class="modal modal-transition" bind:show={isOpen}>
<ModalTitle>Edit Profile</ModalTitle>
<ModalDescription>
You can update your profile here. Hit the save button when finished.
</ModalDescription>
<Label>
Name
<input type="text" placeholder="John Doe" />
</Label>
<Label>
Email
<input type="text" placeholder="johndoe@gmail.com" />
</Label>
<footer>
<button onClick$={() => (isOpen.value = false)}>Cancel</button>
<button onClick$={() => (isOpen.value = false)}>Save Changes</button>
</footer>
</Modal>
</div>
);
});
To add an transition, use the data-open
, data-closing
and data-closed
data attributes. Above is a snippet where we transition both the modal and backdrop's opacity.
Adding an animation
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Modal, ModalTitle, ModalDescription, Label } from '@qwik-ui/headless';
import styles from '../snippets/animation.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(false);
return (
<div class="modal-container">
<button class="modal-trigger" onClick$={() => (isOpen.value = true)}>
Open Modal
</button>
<Modal class="modal modal-animation" bind:show={isOpen}>
<ModalTitle>Edit Profile</ModalTitle>
<ModalDescription>
You can update your profile here. Hit the save button when finished.
</ModalDescription>
<Label>
Name
<input type="text" placeholder="John Doe" />
</Label>
<Label>
Email
<input type="text" placeholder="johndoe@gmail.com" />
</Label>
<footer>
<button onClick$={() => (isOpen.value = false)}>Cancel</button>
<button onClick$={() => (isOpen.value = false)}>Save Changes</button>
</footer>
</Modal>
</div>
);
});
To add an animation, it's the same as with transitions, using the data-open
and data-closing
data attributes. Below is a snippet of the animation example above.
Backdrop animations
Backdrop animations have also made significant progress in the last year, with support provided in over half of the major browsers, and close to 70% of users.
To add a backdrop animation, make sure to use the ::backdrop
pseudo selector to the end of the data-closing
or data-open
classes.
Sheets
Sheets are a type of modal/overlay used to provide temporary access to important information, while also being easily dismissible.
Side Sheet
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Label, Modal, ModalDescription, ModalTitle } from '@qwik-ui/headless';
import styles from '../snippets/animation.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(false);
return (
<>
<div class="modal-container">
<button class="modal-trigger" onClick$={() => (isOpen.value = true)}>
Open Modal
</button>
<Modal class="modal sheet" bind:show={isOpen}>
<ModalTitle>Edit Profile</ModalTitle>
<ModalDescription>
You can update your profile here. Hit the save button when finished.
</ModalDescription>
<Label>
Name
<input type="text" placeholder="John Doe" />
</Label>
<Label>
Email
<input type="text" placeholder="johndoe@gmail.com" />
</Label>
<footer>
<button onClick$={() => (isOpen.value = false)}>Cancel</button>
<button onClick$={() => (isOpen.value = false)}>Save Changes</button>
</footer>
</Modal>
</div>
</>
);
});
Bottom Sheet
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Label, Modal, ModalDescription, ModalTitle } from '@qwik-ui/headless';
import styles from '../snippets/animation.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(false);
return (
<>
<div class="modal-container">
<button class="modal-trigger" onClick$={() => (isOpen.value = true)}>
Open Modal
</button>
<Modal class="modal bottom-sheet" bind:show={isOpen}>
<ModalTitle>Edit Profile</ModalTitle>
<ModalDescription>
You can update your profile here. Hit the save button when finished.
</ModalDescription>
<Label>
Name
<input type="text" placeholder="John Doe" />
</Label>
<Label>
Email
<input type="text" placeholder="johndoe@gmail.com" />
</Label>
<footer>
<button onClick$={() => (isOpen.value = false)}>Cancel</button>
<button onClick$={() => (isOpen.value = false)}>Save Changes</button>
</footer>
</Modal>
</div>
</>
);
});
Bottom sheets are more prevalent in mobile applications, usually to simplify UI. That said, feel free to use them wherever fits best!
Animations / Transitions CSS
:root {
--modal-animation: forwards cubic-bezier(0.6, 0.6, 0, 1);
}
@keyframes modalFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modalFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.modal-animation::backdrop,
.sheet::backdrop,
.bottom-sheet::backdrop {
animation: modalFadeIn 0.75s var(--modal-animation);
}
.modal-animation[data-closing]::backdrop,
.sheet[data-closing]::backdrop,
.bottom-sheet[data-closing]::backdrop {
animation: modalFadeOut 0.35s var(--modal-animation);
}
.modal-animation::backdrop {
background: hsla(0, 0%, 0%, 0.5);
}
@keyframes modalOpen {
from {
opacity: 0;
transform: scale(150%);
}
to {
opacity: 1;
transform: scale(100%);
}
}
@keyframes modalClose {
from {
opacity: 1;
transform: translateY(0%);
}
to {
opacity: 0;
transform: translateY(-200%);
}
}
.modal-animation {
animation: modalOpen 0.75s var(--modal-animation);
}
.modal-animation[data-closing] {
animation: modalClose 0.35s var(--modal-animation);
}
@keyframes sheetOpen {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0%);
}
}
@keyframes sheetClose {
from {
opacity: 1;
transform: translateX(0%);
}
to {
opacity: 0;
transform: translateX(100%);
}
}
.sheet::backdrop {
background: hsla(0, 0%, 0%, 0.5);
}
.sheet {
right: 0;
margin-right: 0;
height: 100vh;
animation: sheetOpen 0.75s var(--modal-animation);
}
.sheet[data-closing] {
animation: sheetClose 0.35s var(--modal-animation);
}
@keyframes bottomSheetOpen {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0%);
}
}
@keyframes bottomSheetClose {
from {
opacity: 1;
transform: translateY(0%);
}
to {
opacity: 0;
transform: translateY(100%);
}
}
.bottom-sheet {
height: auto;
top: unset;
bottom: 0;
margin-bottom: 0;
animation: bottomSheetOpen 0.75s var(--modal-animation);
}
.bottom-sheet[data-closing] {
animation: bottomSheetClose 0.35s var(--modal-animation);
}
.modal-transition {
opacity: 0;
transition: opacity 350ms ease;
}
.modal-transition[data-open] {
opacity: 1;
}
Open on page load
There might come a time where a modal is needed open as soon as the page loads. Unfortunately, to create modals a client-side API is currently needed regardless of implementation.
Example CSS
The example for the CSS used in all of the examples above can be found below.
.modal,
.modal-trigger {
border-radius: calc(var(--border-radius));
}
.modal-trigger {
padding: 0.5rem;
width: 100%;
border: 2px dotted hsla(var(--foreground));
height: 44px;
width: fit-content;
}
.modal {
padding: 1rem;
gap: 0.5rem;
border: 2px dotted hsla(var(--primary));
color: hsla(var(--foreground));
background-color: hsl(var(--background));
max-width: 28rem;
overflow: hidden;
}
.modal::backdrop {
backdrop-filter: blur(3px) brightness(50%);
}
.modal > * {
padding-block: 0.5rem;
}
.modal input {
margin-bottom: 1rem;
border-radius: calc(var(--border-radius));
background: hsla(var(--foreground) / 0.05);
}
.modal h2 {
font-size: 1.5rem;
font-weight: 800;
padding: 0;
}
.modal input {
width: 100%;
padding: 0.5rem;
border-radius: calc(var(--border-radius));
border: 2px dotted hsla(var(--ring));
}
.modal footer {
padding-top: 0;
display: flex;
gap: 0.5rem;
}
.modal button {
padding: 0.5rem;
border-radius: calc(var(--border-radius));
border: 2px dotted hsla(var(--muted));
}
.modal button:first-of-type {
border-color: hsla(var(--foreground));
}
.modal button:last-of-type {
border-color: hsla(var(--primary));
}
.modal-backdrop::backdrop {
/* changing background */
background: rgba(0, 0, 0, 0.4);
/* providing multiple filters */
backdrop-filter: grayscale(90%) blur(10px);
}
.modal .modal-close {
position: absolute;
top: 0;
right: 0.5rem;
transform: scale(3) rotate(45deg);
border: none;
outline: none;
}
/* some css fun here lol */
.modal .modal-close:focus-visible::after {
content: '';
position: absolute;
left: 0.35rem;
right: 0.55rem;
top: 0.7rem;
bottom: 0.7rem;
border: 1px solid hsla(var(--foreground));
transform: rotate(-45deg);
pointer-events: none;
}
SSR dilemma
Qwik is the first framework with an SSR portal implementation. SSR Portal capabilities are unique to Qwik City, because the alternative frameworks require a client-side API for portal functionality, such as document.appendChild
.
The problem with rendering the modal in the server, is that we lose some critical behavior:
- The content behind the dialog is not inert (non-modal)
- Focus and scroll lock customizations break
- Will not work with meta frameworks like Astro.js
The current solution across framework ecosystems, is to open the Modal eagerly on the client to keep the proper functionality.
import { component$, useSignal } from '@builder.io/qwik';
import { Modal, ModalDescription, ModalTitle } from '@qwik-ui/headless';
export default component$(() => {
const showSig = useSignal(false);
// uncomment this
// useVisibleTask$(
// () => {
// showSig.value = true;
// },
// { strategy: 'document-ready' },
// );
return (
<Modal bind:show={showSig}>
<ModalTitle />
<ModalDescription />
{/* other content */}
</Modal>
);
});
Keyboard interaction
Key | Description |
---|---|
Escape | |
Tab | |
Shift + Tab |
API
Prop | Type | Description |
---|---|---|
bind:show | Signal boolean | Toggle between showing or hiding the modal. |
closeOnBackdropClick | boolean | A way to tell the modal to not hide when the ::backdrop is clicked. |
<dialog>-Attributes | Qwik PropsOf<'dialog'> | A way to configure the modal with all native attributes the HTMLDialog defines. |
onClose()$ | function () => void | An event hook that gets notified whenever the modal gets closed. |
onShow()$ | function () => void | An event hook that gets notified whenever the modal shows up. |
data-open | attribute | A data attribute used for styling open modals and entry animations. |
data-closing | attribute | A data attribute used for exit animations. |
data-closed | attribute | A data attribute used for closed modals. |