Modal
Diálogo modal basado en el elemento dialog nativo.
- Navegadores
- Chrome 123+Firefox 121+Safari 17.5+
- Nivel
- 0
- Tamaño comprimido
- 944 B
- Presupuesto de diseño forzado
- 0 ms
- Presupuesto de axe
- 0
- Compatible con SSR
- Sí
- Basado en
- elements/dialog
- Contrato publicado
- Entorno de pruebas
Zona de pruebas
Modal
Cerrado
Controlado
Cambios: , estado:
import { Modal } from "@gunkin/uiify";
export default function Example() {
return (
<div className="">
<Modal
id="settings-dialog"
trigger="Open settings"
title="Settings"
description="Update your profile preferences."
/>
</div>
);
}/* Native dialog surfaces. */
@layer components {
:where(
[data-uiify-dialog][data-part="content"],
[data-uiify-modal][data-part="content"],
[data-uiify-alert-modal][data-part="content"],
[data-uiify-drawer][data-part="content"]
) {
margin: auto;
inline-size: min(28rem, calc(100vw - 2 * var(--space-lg)));
padding: var(--space-xl);
border: var(--border-width) solid var(--border);
/* One-off: the modal is the highest-elevation surface in the 2a/2b
handoff (uiify Components Kit.dc.html), rounder than the --radius-lg
card scale (12px) that every other bordered container uses. */
border-radius: var(--radius-xl);
corner-shape: squircle;
background-color: var(--popover);
color: var(--popover-foreground);
box-shadow: var(--shadow-modal);
}
:where(
[data-uiify-dialog][data-part="content"],
[data-uiify-modal][data-part="content"],
[data-uiify-alert-modal][data-part="content"],
[data-uiify-drawer][data-part="content"]
)::backdrop {
background-color: rgb(0 0 0 / 45%);
}
:where([data-uiify-dialog], [data-uiify-modal], [data-uiify-alert-modal], [data-uiify-drawer])
[data-part="title"] {
margin: 0;
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
line-height: 1.3;
}
:where([data-uiify-dialog], [data-uiify-modal], [data-uiify-alert-modal], [data-uiify-drawer])
[data-part="description"] {
margin-block: var(--space-sm) var(--space-lg);
color: var(--muted-foreground);
font-size: var(--text-sm);
line-height: var(--leading-normal);
}
@media (prefers-reduced-motion: no-preference) {
:where(
[data-uiify-dialog][data-part="content"],
[data-uiify-modal][data-part="content"],
[data-uiify-alert-modal][data-part="content"],
[data-uiify-drawer][data-part="content"]
),
:where(
[data-uiify-dialog][data-part="content"],
[data-uiify-modal][data-part="content"],
[data-uiify-alert-modal][data-part="content"],
[data-uiify-drawer][data-part="content"]
)::backdrop {
transition: var(--transition-presence);
}
:where(
[data-uiify-dialog][data-part="content"],
[data-uiify-modal][data-part="content"],
[data-uiify-alert-modal][data-part="content"]
):not([open]) {
opacity: 0;
scale: var(--presence-scale);
}
:where(
[data-uiify-dialog][data-part="content"],
[data-uiify-modal][data-part="content"],
[data-uiify-alert-modal][data-part="content"],
[data-uiify-drawer][data-part="content"]
):not([open])::backdrop {
opacity: 0;
}
@starting-style {
:where(
[data-uiify-dialog][data-part="content"],
[data-uiify-modal][data-part="content"],
[data-uiify-alert-modal][data-part="content"]
)[open] {
opacity: 0;
scale: var(--presence-scale);
}
:where(
[data-uiify-dialog][data-part="content"],
[data-uiify-modal][data-part="content"],
[data-uiify-alert-modal][data-part="content"],
[data-uiify-drawer][data-part="content"]
)[open]::backdrop {
opacity: 0;
}
}
}
/* Modal anatomy. AlertModal, Drawer and Dialog keep the shared surface above; every rule
below is scoped to `[data-uiify-modal]`, so their look is unchanged.
The column layout sits on the base rule, not under [open]: on close `display` stays flex
through the exit transition (allow-discrete), so the dialog must keep its column until it
is hidden. Only `display` depends on [open]. */
[data-uiify-modal][data-part="content"] {
--uiify-modal-inline-size: 28rem;
flex-direction: column;
gap: var(--space-md);
inline-size: min(var(--uiify-modal-inline-size), calc(100vw - 2 * var(--space-lg)));
max-block-size: calc(100dvh - 2 * var(--space-lg));
}
[data-uiify-modal][data-part="content"][open] {
display: flex;
}
[data-uiify-modal][data-part="content"][data-size="sm"] {
--uiify-modal-inline-size: 24rem;
}
[data-uiify-modal][data-part="content"][data-size="lg"] {
--uiify-modal-inline-size: 40rem;
}
[data-uiify-modal][data-part="content"][data-size="full"] {
inset: 0;
margin: 0;
inline-size: 100%;
block-size: 100%;
max-inline-size: none;
max-block-size: none;
padding-block: max(var(--space-xl), env(safe-area-inset-top))
max(var(--space-xl), env(safe-area-inset-bottom));
padding-inline: max(var(--space-xl), env(safe-area-inset-left))
max(var(--space-xl), env(safe-area-inset-right));
border: 0;
border-radius: 0;
}
/* Below 40rem the bottom sheet wins, so align=start only applies from the breakpoint up. */
@media (width >= 40rem) {
[data-uiify-modal][data-part="content"][data-align="start"]:not([data-size="full"]) {
margin-block: var(--space-xl) auto;
}
}
[data-uiify-modal][data-part="header"] {
position: relative;
flex: none;
padding-inline-end: 2.5rem;
}
/* Every close button shares one soft look: a filled, borderless surface that steps up on
hover and press. The header icon is a circle; text closers (footer, body) keep the pill. */
[data-uiify-modal][data-part="close"][data-uiify-button] {
border-color: transparent;
background-color: color-mix(in oklch, var(--foreground) 8%, transparent);
color: var(--foreground);
}
[data-uiify-modal][data-part="close"][data-uiify-button]:hover {
background-color: color-mix(in oklch, var(--foreground) 14%, transparent);
}
[data-uiify-modal][data-part="close"][data-uiify-button]:active {
background-color: color-mix(in oklch, var(--foreground) 20%, transparent);
}
[data-uiify-modal][data-part="header"] > [data-part="close"] {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
display: grid;
place-items: center;
inline-size: 2rem;
block-size: 2rem;
min-block-size: 0;
padding: 0;
border-radius: 999px;
}
[data-uiify-modal][data-part="body"] {
flex: 1 1 auto;
min-block-size: 0;
overflow: auto;
overscroll-behavior: contain;
}
[data-uiify-modal][data-part="footer"] {
display: flex;
flex: none;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-sm);
}
@media (pointer: coarse) {
[data-uiify-modal][data-part="header"] {
padding-inline-end: 2.75rem;
}
[data-uiify-modal][data-part="header"] > [data-part="close"] {
min-inline-size: 2.75rem;
min-block-size: 2.75rem;
}
[data-uiify-modal][data-part="footer"] > * {
min-block-size: 2.75rem;
}
}
/* Mobile sheet below 40rem (keep in sync with SHEET_QUERY in enhance/sheet-swipe.ts):
at least 50dvh and at most 80dvh, so the top 20% of the viewport is always backdrop. */
[data-uiify-modal][data-part="grabber"] {
display: none;
flex: none;
block-size: 1.5rem;
margin-block: calc(-1 * var(--space-md)) calc(-1 * var(--space-sm));
touch-action: none;
}
[data-uiify-modal][data-part="grabber"]::after {
content: "";
display: block;
inline-size: 2.25rem;
block-size: 0.25rem;
margin-block: 0.5rem 0;
margin-inline: auto;
border-radius: 999px;
background-color: var(--border);
}
@media (width < 40rem) {
[data-uiify-modal][data-part="content"]:not([data-size="full"]) {
inline-size: 100%;
max-inline-size: 100%;
min-block-size: 50dvh;
max-block-size: 80dvh;
margin-block: auto 0;
margin-inline: 0;
padding-block-end: calc(var(--space-xl) + env(safe-area-inset-bottom));
border-block-end-width: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
/* With a ModalBody the body is the scroller, so the rest of the sheet is a swipe surface.
Without one the dialog itself scrolls and keeps the default touch action; the header and
grabber stay swipe handles either way (touch-action applies to the element touched). */
[data-uiify-modal][data-part="content"]:not([data-size="full"]):has(
> [data-uiify-modal][data-part="body"]
) {
touch-action: none;
}
[data-uiify-modal][data-part="content"]:not([data-size="full"])
[data-uiify-modal][data-part="header"] {
touch-action: none;
}
[data-uiify-modal][data-part="body"] {
touch-action: pan-y;
}
[data-uiify-modal][data-part="content"]:not([data-size="full"]) > [data-part="grabber"] {
display: block;
}
}
@media (width < 40rem) and (prefers-reduced-motion: no-preference) {
[data-uiify-modal][data-part="content"]:not([data-size="full"]) {
transition:
translate var(--duration-fast) var(--ease-out),
opacity var(--duration-fast) var(--ease-out),
display var(--duration-fast) allow-discrete,
overlay var(--duration-fast) allow-discrete;
}
[data-uiify-modal][data-part="content"]:not([data-size="full"], [open]) {
translate: 0 100%;
scale: 1;
}
@starting-style {
[data-uiify-modal][data-part="content"]:not([data-size="full"])[open] {
translate: 0 100%;
scale: 1;
}
}
}
/* While a finger drives the sheet (see enhance/sheet-swipe.ts): no transition, position
from the offset. Removing the attribute hands the position back to the rules above,
which animates the sheet out or back. */
[data-uiify-modal][data-part="content"][data-dragging][open]:not([data-size="full"]) {
transition: none;
translate: 0 var(--uiify-sheet-offset, 0);
}
[data-uiify-modal][data-part="content"][data-backdrop="blur"]::backdrop {
backdrop-filter: blur(4px);
}
[data-uiify-modal][data-part="content"][data-backdrop="none"]::backdrop {
background-color: transparent;
}
/* Nested modals: the child is a DOM descendant of the parent, so CSS can see it. The
parent's own backdrop keeps dimming the page (one layer); the parent steps back. */
[data-uiify-modal][data-part="content"] [data-uiify-modal][data-part="content"]::backdrop {
background-color: transparent;
backdrop-filter: none;
}
[data-uiify-modal][data-part="content"]:has([data-uiify-modal][data-part="content"]:modal) {
filter: brightness(0.9);
}
@media (width >= 40rem) {
[data-uiify-modal][data-part="content"]:has([data-uiify-modal][data-part="content"]:modal) {
scale: 0.97;
}
}
}import { Modal } from "@gunkin/uiify";
export default function Example() {
return (
<div
className={
"[&[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)] [&_[data-positioning=native]]:[--uiify" +
"-anchor-offset:var(--space-sm)] [&[popover]:popover-open:not([data-positioning=native])]:[margin:revert] [&" +
"_[popover]:popover-open:not([data-positioning=native])]:[margin:revert] [&[data-part=content]]:m-auto [&[da" +
"ta-part=content]]:[inline-size:min(28rem,_calc(100vw_-_2_*_var(--space-lg)))] [&[data-part=content]]:p-xl [" +
"&[data-part=content]]:border-[length:var(--border-width)] [&[data-part=content]]:border-solid [&[data-part=" +
"content]]:border-border [&[data-part=content]]:rounded-xl [&[data-part=content]]:[corner-shape:squircle] [&" +
"[data-part=content]]:bg-popover [&[data-part=content]]:text-popover-foreground [&[data-part=content]]:shado" +
"w-modal [&[data-part=content]::backdrop]:[background-color:rgb(0_0_0_/_45%)] [&_[data-part=title]]:m-0 [&_[" +
"data-part=title]]:text-[length:var(--text-xl)] [&_[data-part=title]]:font-[number:var(--weight-semibold)] [" +
"&_[data-part=title]]:[line-height:1.3] [&_[data-part=description]]:[margin-block:var(--space-sm)_var(--spac" +
"e-lg)] [&_[data-part=description]]:text-muted-foreground [&_[data-part=description]]:text-sm [&_[data-part=" +
"description]]:[line-height:var(--leading-normal)] motion-safe:[&[data-part=content]]:[transition:var(--tran" +
"sition-presence)] motion-safe:[&[data-part=content]::backdrop]:[transition:var(--transition-presence)] moti" +
"on-safe:[&[data-part=content]:not([open])]:opacity-0 motion-safe:[&[data-part=content]:not([open])]:[scale:" +
"var(--presence-scale)] motion-safe:[&[data-part=content]:not([open])::backdrop]:opacity-0 motion-safe:start" +
"ing:[&[data-part=content][open]]:opacity-0 motion-safe:starting:[&[data-part=content][open]]:[scale:var(--p" +
"resence-scale)] motion-safe:starting:[&[data-part=content][open]::backdrop]:opacity-0 data-[part=content]:[" +
"--uiify-modal-inline-size:28rem] data-[part=content]:flex-col data-[part=content]:gap-md data-[part=content" +
"]:[inline-size:min(var(--uiify-modal-inline-size),_calc(100vw_-_2_*_var(--space-lg)))] data-[part=content]:" +
"[max-block-size:calc(100dvh_-_2_*_var(--space-lg))] data-[part=content]:[&[open]]:flex data-[part=content]:" +
"data-[size=sm]:[--uiify-modal-inline-size:24rem] data-[part=content]:data-[size=lg]:[--uiify-modal-inline-s" +
"ize:40rem] data-[part=content]:data-[size=full]:inset-0 data-[part=content]:data-[size=full]:m-0 data-[part" +
"=content]:data-[size=full]:[inline-size:100%] data-[part=content]:data-[size=full]:[block-size:100%] data-[" +
"part=content]:data-[size=full]:[max-inline-size:none] data-[part=content]:data-[size=full]:[max-block-size:" +
"none] data-[part=content]:data-[size=full]:[padding-block:max(var(--space-xl),_env(safe-area-inset-top))_ma" +
"x(var(--space-xl),_env(safe-area-inset-bottom))] data-[part=content]:data-[size=full]:[padding-inline:max(v" +
"ar(--space-xl),_env(safe-area-inset-left))_max(var(--space-xl),_env(safe-area-inset-right))] data-[part=con" +
"tent]:data-[size=full]:border-0 data-[part=content]:data-[size=full]:border-current data-[part=content]:dat" +
"a-[size=full]:border-none data-[part=content]:data-[size=full]:rounded-none [@media_(width_>=_40rem)]:data-" +
"[part=content]:data-[align=start]:[&:not([data-size=full])]:[margin-block:var(--space-xl)_auto] data-[part=" +
"header]:relative data-[part=header]:flex-none data-[part=header]:[padding-inline-end:2.5rem] data-[part=clo" +
"se]:data-[uiify-button]:border-transparent data-[part=close]:data-[uiify-button]:[background-color:color-mi" +
"x(in_oklch,_var(--foreground)_8%,_transparent)] data-[part=close]:data-[uiify-button]:text-foreground data-" +
"[part=close]:data-[uiify-button]:hover:[background-color:color-mix(in_oklch,_var(--foreground)_14%,_transpa" +
"rent)] data-[part=close]:data-[uiify-button]:active:[background-color:color-mix(in_oklch,_var(--foreground)" +
"_20%,_transparent)] [&[data-part=header]>[data-part=close]]:absolute [&[data-part=header]>[data-part=close]" +
"]:[inset-block-start:0] [&[data-part=header]>[data-part=close]]:[inset-inline-end:0] [&[data-part=header]>[" +
"data-part=close]]:grid [&[data-part=header]>[data-part=close]]:place-items-center [&[data-part=header]>[dat" +
"a-part=close]]:[inline-size:2rem] [&[data-part=header]>[data-part=close]]:[block-size:2rem] [&[data-part=he" +
"ader]>[data-part=close]]:[min-block-size:0] [&[data-part=header]>[data-part=close]]:p-0 [&[data-part=header" +
"]>[data-part=close]]:[border-radius:999px] data-[part=body]:flex-auto data-[part=body]:[min-block-size:0] d" +
"ata-[part=body]:overflow-auto data-[part=body]:overscroll-contain data-[part=footer]:flex data-[part=footer" +
"]:flex-none data-[part=footer]:flex-wrap data-[part=footer]:justify-end data-[part=footer]:gap-sm [@media_(" +
"pointer:_coarse)]:data-[part=header]:[padding-inline-end:2.75rem] [@media_(pointer:_coarse)]:[&[data-part=h" +
"eader]>[data-part=close]]:[min-inline-size:2.75rem] [@media_(pointer:_coarse)]:[&[data-part=header]>[data-p" +
"art=close]]:[min-block-size:2.75rem] [@media_(pointer:_coarse)]:[&[data-part=footer]>*]:[min-block-size:2.7" +
"5rem] data-[part=grabber]:hidden data-[part=grabber]:flex-none data-[part=grabber]:[block-size:1.5rem] data" +
"-[part=grabber]:[margin-block:calc(-1_*_var(--space-md))_calc(-1_*_var(--space-sm))] data-[part=grabber]:[t" +
"ouch-action:none] data-[part=grabber]:after:content-[''] data-[part=grabber]:after:block data-[part=grabber" +
"]:after:[inline-size:2.25rem] data-[part=grabber]:after:[block-size:0.25rem] data-[part=grabber]:after:[mar" +
"gin-block:0.5rem_0] data-[part=grabber]:after:[margin-inline:auto] data-[part=grabber]:after:[border-radius" +
":999px] data-[part=grabber]:after:bg-border [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=" +
"full])]:[inline-size:100%] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[max-inli" +
"ne-size:100%] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[min-block-size:50dvh]" +
" [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[max-block-size:80dvh] [@media_(wid" +
"th_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[margin-block:auto_0] [@media_(width_<_40rem)]:d" +
"ata-[part=content]:[&:not([data-size=full])]:[margin-inline:0] [@media_(width_<_40rem)]:data-[part=content]" +
":[&:not([data-size=full])]:[padding-block-end:calc(var(--space-xl)_+_env(safe-area-inset-bottom))] [@media_" +
"(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[border-block-end-width:0] [@media_(width_<_" +
"40rem)]:data-[part=content]:[&:not([data-size=full])]:[border-end-start-radius:0] [@media_(width_<_40rem)]:" +
"data-[part=content]:[&:not([data-size=full])]:[border-end-end-radius:0] [@media_(width_<_40rem)]:data-[part" +
"=content]:[&:not([data-size=full]):has(>[data-uiify-modal][data-part=body])]:[touch-action:none] [@media_(w" +
"idth_<_40rem)]:data-[part=content]:[&:not([data-size=full])_[data-uiify-modal][data-part=header]]:[touch-ac" +
"tion:none] [@media_(width_<_40rem)]:data-[part=body]:[touch-action:pan-y] [@media_(width_<_40rem)]:data-[pa" +
"rt=content]:[&:not([data-size=full])>[data-part=grabber]]:block [@media_(width_<_40rem)_and_(prefers-reduce" +
"d-motion:_no-preference)]:data-[part=content]:[&:not([data-size=full])]:[transition:translate_var(--duratio" +
"n-fast)_var(--ease-out),_opacity_var(--duration-fast)_var(--ease-out),_display_var(--duration-fast)_allow-d" +
"iscrete,_overlay_var(--duration-fast)_allow-discrete] [@media_(width_<_40rem)_and_(prefers-reduced-motion:_" +
"no-preference)]:data-[part=content]:[&:not([data-size=full],[open])]:[translate:0_100%] [@media_(width_<_40" +
"rem)_and_(prefers-reduced-motion:_no-preference)]:data-[part=content]:[&:not([data-size=full],[open])]:[sca" +
"le:1] [@media_(width_<_40rem)_and_(prefers-reduced-motion:_no-preference)]:starting:data-[part=content]:[&:" +
"not([data-size=full])[open]]:[translate:0_100%] [@media_(width_<_40rem)_and_(prefers-reduced-motion:_no-pre" +
"ference)]:starting:data-[part=content]:[&:not([data-size=full])[open]]:[scale:1] data-[part=content]:data-[" +
"dragging]:[&[open]:not([data-size=full])]:[transition:none] data-[part=content]:data-[dragging]:[&[open]:no" +
"t([data-size=full])]:[translate:0_var(--uiify-sheet-offset,_0)] data-[part=content]:data-[backdrop=blur]:ba" +
"ckdrop:[backdrop-filter:blur(4px)] data-[part=content]:data-[backdrop=none]:backdrop:bg-transparent [&[data" +
"-part=content]_[data-uiify-modal][data-part=content]::backdrop]:bg-transparent [&[data-part=content]_[data-" +
"uiify-modal][data-part=content]::backdrop]:[backdrop-filter:none] data-[part=content]:[&:has([data-uiify-mo" +
"dal][data-part=content]:modal)]:[filter:brightness(0.9)] [@media_(width_>=_40rem)]:data-[part=content]:[&:h" +
"as([data-uiify-modal][data-part=content]:modal)]:[scale:0.97]"
}
>
<Modal
id="settings-dialog"
trigger="Open settings"
title="Settings"
description="Update your profile preferences."
/>
</div>
);
}/* app.css */
@layer reset, ui, app, tokens, base, demo, components, utilities, theme;
@import "@gunkin/uiify/styles/reset";
@import "@gunkin/uiify/components/behavior.css";
@import "@gunkin/uiify/styles/tokens";
@import "@gunkin/uiify/styles/tailwind";
@import "tailwindcss/utilities.css";
@theme { --spacing: 0.25rem; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; }
@source inline('[&[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)] [&_[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)] [&[popover]:popover-open:not([data-positioning=native])]:[margin:revert] [&_[popover]:popover-open:not([data-positioning=native])]:[margin:revert] [&[data-part=content]]:m-auto [&[data-part=content]]:[inline-size:min(28rem,_calc(100vw_-_2_*_var(--space-lg)))] [&[data-part=content]]:p-xl [&[data-part=content]]:border-[length:var(--border-width)] [&[data-part=content]]:border-solid [&[data-part=content]]:border-border [&[data-part=content]]:rounded-xl [&[data-part=content]]:[corner-shape:squircle] [&[data-part=content]]:bg-popover [&[data-part=content]]:text-popover-foreground [&[data-part=content]]:shadow-modal [&[data-part=content]::backdrop]:[background-color:rgb(0_0_0_/_45%)] [&_[data-part=title]]:m-0 [&_[data-part=title]]:text-[length:var(--text-xl)] [&_[data-part=title]]:font-[number:var(--weight-semibold)] [&_[data-part=title]]:[line-height:1.3] [&_[data-part=description]]:[margin-block:var(--space-sm)_var(--space-lg)] [&_[data-part=description]]:text-muted-foreground [&_[data-part=description]]:text-sm [&_[data-part=description]]:[line-height:var(--leading-normal)] motion-safe:[&[data-part=content]]:[transition:var(--transition-presence)] motion-safe:[&[data-part=content]::backdrop]:[transition:var(--transition-presence)] motion-safe:[&[data-part=content]:not([open])]:opacity-0 motion-safe:[&[data-part=content]:not([open])]:[scale:var(--presence-scale)] motion-safe:[&[data-part=content]:not([open])::backdrop]:opacity-0 motion-safe:starting:[&[data-part=content][open]]:opacity-0 motion-safe:starting:[&[data-part=content][open]]:[scale:var(--presence-scale)] motion-safe:starting:[&[data-part=content][open]::backdrop]:opacity-0 data-[part=content]:[--uiify-modal-inline-size:28rem] data-[part=content]:flex-col data-[part=content]:gap-md data-[part=content]:[inline-size:min(var(--uiify-modal-inline-size),_calc(100vw_-_2_*_var(--space-lg)))] data-[part=content]:[max-block-size:calc(100dvh_-_2_*_var(--space-lg))] data-[part=content]:[&[open]]:flex data-[part=content]:data-[size=sm]:[--uiify-modal-inline-size:24rem] data-[part=content]:data-[size=lg]:[--uiify-modal-inline-size:40rem] data-[part=content]:data-[size=full]:inset-0 data-[part=content]:data-[size=full]:m-0 data-[part=content]:data-[size=full]:[inline-size:100%] data-[part=content]:data-[size=full]:[block-size:100%] data-[part=content]:data-[size=full]:[max-inline-size:none] data-[part=content]:data-[size=full]:[max-block-size:none] data-[part=content]:data-[size=full]:[padding-block:max(var(--space-xl),_env(safe-area-inset-top))_max(var(--space-xl),_env(safe-area-inset-bottom))] data-[part=content]:data-[size=full]:[padding-inline:max(var(--space-xl),_env(safe-area-inset-left))_max(var(--space-xl),_env(safe-area-inset-right))] data-[part=content]:data-[size=full]:border-0 data-[part=content]:data-[size=full]:border-current data-[part=content]:data-[size=full]:border-none data-[part=content]:data-[size=full]:rounded-none [@media_(width_>=_40rem)]:data-[part=content]:data-[align=start]:[&:not([data-size=full])]:[margin-block:var(--space-xl)_auto] data-[part=header]:relative data-[part=header]:flex-none data-[part=header]:[padding-inline-end:2.5rem] data-[part=close]:data-[uiify-button]:border-transparent data-[part=close]:data-[uiify-button]:[background-color:color-mix(in_oklch,_var(--foreground)_8%,_transparent)] data-[part=close]:data-[uiify-button]:text-foreground data-[part=close]:data-[uiify-button]:hover:[background-color:color-mix(in_oklch,_var(--foreground)_14%,_transparent)] data-[part=close]:data-[uiify-button]:active:[background-color:color-mix(in_oklch,_var(--foreground)_20%,_transparent)] [&[data-part=header]>[data-part=close]]:absolute [&[data-part=header]>[data-part=close]]:[inset-block-start:0] [&[data-part=header]>[data-part=close]]:[inset-inline-end:0] [&[data-part=header]>[data-part=close]]:grid [&[data-part=header]>[data-part=close]]:place-items-center [&[data-part=header]>[data-part=close]]:[inline-size:2rem] [&[data-part=header]>[data-part=close]]:[block-size:2rem] [&[data-part=header]>[data-part=close]]:[min-block-size:0] [&[data-part=header]>[data-part=close]]:p-0 [&[data-part=header]>[data-part=close]]:[border-radius:999px] data-[part=body]:flex-auto data-[part=body]:[min-block-size:0] data-[part=body]:overflow-auto data-[part=body]:overscroll-contain data-[part=footer]:flex data-[part=footer]:flex-none data-[part=footer]:flex-wrap data-[part=footer]:justify-end data-[part=footer]:gap-sm [@media_(pointer:_coarse)]:data-[part=header]:[padding-inline-end:2.75rem] [@media_(pointer:_coarse)]:[&[data-part=header]>[data-part=close]]:[min-inline-size:2.75rem] [@media_(pointer:_coarse)]:[&[data-part=header]>[data-part=close]]:[min-block-size:2.75rem] [@media_(pointer:_coarse)]:[&[data-part=footer]>*]:[min-block-size:2.75rem] data-[part=grabber]:hidden data-[part=grabber]:flex-none data-[part=grabber]:[block-size:1.5rem] data-[part=grabber]:[margin-block:calc(-1_*_var(--space-md))_calc(-1_*_var(--space-sm))] data-[part=grabber]:[touch-action:none] data-[part=grabber]:after:block data-[part=grabber]:after:[inline-size:2.25rem] data-[part=grabber]:after:[block-size:0.25rem] data-[part=grabber]:after:[margin-block:0.5rem_0] data-[part=grabber]:after:[margin-inline:auto] data-[part=grabber]:after:[border-radius:999px] data-[part=grabber]:after:bg-border [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[inline-size:100%] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[max-inline-size:100%] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[min-block-size:50dvh] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[max-block-size:80dvh] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[margin-block:auto_0] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[margin-inline:0] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[padding-block-end:calc(var(--space-xl)_+_env(safe-area-inset-bottom))] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[border-block-end-width:0] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[border-end-start-radius:0] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])]:[border-end-end-radius:0] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full]):has(>[data-uiify-modal][data-part=body])]:[touch-action:none] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])_[data-uiify-modal][data-part=header]]:[touch-action:none] [@media_(width_<_40rem)]:data-[part=body]:[touch-action:pan-y] [@media_(width_<_40rem)]:data-[part=content]:[&:not([data-size=full])>[data-part=grabber]]:block [@media_(width_<_40rem)_and_(prefers-reduced-motion:_no-preference)]:data-[part=content]:[&:not([data-size=full])]:[transition:translate_var(--duration-fast)_var(--ease-out),_opacity_var(--duration-fast)_var(--ease-out),_display_var(--duration-fast)_allow-discrete,_overlay_var(--duration-fast)_allow-discrete] [@media_(width_<_40rem)_and_(prefers-reduced-motion:_no-preference)]:data-[part=content]:[&:not([data-size=full],[open])]:[translate:0_100%] [@media_(width_<_40rem)_and_(prefers-reduced-motion:_no-preference)]:data-[part=content]:[&:not([data-size=full],[open])]:[scale:1] [@media_(width_<_40rem)_and_(prefers-reduced-motion:_no-preference)]:starting:data-[part=content]:[&:not([data-size=full])[open]]:[translate:0_100%] [@media_(width_<_40rem)_and_(prefers-reduced-motion:_no-preference)]:starting:data-[part=content]:[&:not([data-size=full])[open]]:[scale:1] data-[part=content]:data-[dragging]:[&[open]:not([data-size=full])]:[transition:none] data-[part=content]:data-[dragging]:[&[open]:not([data-size=full])]:[translate:0_var(--uiify-sheet-offset,_0)] data-[part=content]:data-[backdrop=blur]:backdrop:[backdrop-filter:blur(4px)] data-[part=content]:data-[backdrop=none]:backdrop:bg-transparent [&[data-part=content]_[data-uiify-modal][data-part=content]::backdrop]:bg-transparent [&[data-part=content]_[data-uiify-modal][data-part=content]::backdrop]:[backdrop-filter:none] data-[part=content]:[&:has([data-uiify-modal][data-part=content]:modal)]:[filter:brightness(0.9)] [@media_(width_>=_40rem)]:data-[part=content]:[&:has([data-uiify-modal][data-part=content]:modal)]:[scale:0.97]');
@source inline("data-[part=grabber]:after:content-['']");
@layer base { :root { --uiify-control-text: var(--text-sm); } @media (pointer: coarse) { :root { --uiify-control-text: var(--text-base); } } }
/* Authored animation and registered properties used by the Tailwind skin.
* Visual rules belong in utility recipes; only at-rule definitions live here. */
@layer components {
@keyframes uiify-presence-in {
from {
opacity: 0;
scale: var(--presence-scale);
}
}
}
@layer components {
@keyframes uiify-presence-out {
to {
opacity: 0;
scale: var(--presence-scale);
}
}
}
@property --uiify-spinner-tail {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@property --uiify-spinner-length {
syntax: "<angle>";
inherits: false;
initial-value: 36deg;
}
@layer components {
@keyframes uiify-spinner-rotate {
to {
transform: rotate(360deg);
}
}
}
@layer components {
@keyframes uiify-spinner-orbit {
0% {
--uiify-spinner-tail: 0deg;
--uiify-spinner-length: 36deg;
}
50% {
--uiify-spinner-tail: 90deg;
--uiify-spinner-length: 324deg;
}
100% {
--uiify-spinner-tail: 360deg;
--uiify-spinner-length: 36deg;
}
}
}
@layer components {
@keyframes uiify-skeleton-pulse {
50% {
opacity: 0.45;
}
}
}
@layer components {
@keyframes uiify-skeleton-shimmer {
to {
background-position: -200% 0;
}
}
}
@layer components {
@keyframes uiify-skeleton-reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
@property --candle-body-height {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --candle-body-top {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --candle-slot {
syntax: "<percentage>";
inherits: true;
initial-value: 100%;
}
@property --candle-wick-height {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --candle-wick-top {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --candle-x {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --candle-scale {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
@property --line-y {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --line-prev-y {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --line-count {
syntax: "<integer>";
inherits: false;
initial-value: 1;
}
@property --line-scale {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
@layer components {
@keyframes uiify-otp-input-caret-blink {
50% {
opacity: 0;
}
}
}Ejemplos
Sizes
size writes data-size: sm 24rem, md (default) 28rem, lg 40rem, full fills the viewport. On a phone every size except full is a bottom sheet.
import { Modal } from "@gunkin/uiify";
export default function Example() {
return (
<>
<Modal id="size-sm" size="sm" title="Small" trigger="Small">
<p>24rem wide.</p>
</Modal>
<Modal id="size-lg" size="lg" title="Large" trigger="Large">
<p>40rem wide.</p>
</Modal>
</>
);
}Long content
The title, content and footer share one scroll area. Compose the parts inside ModalBody for a document-style dialog.
import { ModalTrigger, ModalContent, ModalBody, ModalHeader, ModalTitle, ModalClose, ModalFooter } from "@gunkin/uiify";
export default function Example() {
return <>
<ModalTrigger target="terms">Read terms</ModalTrigger>
<ModalContent id="terms" aria-labelledby="terms-title">
<ModalBody>
<ModalHeader><ModalTitle id="terms-title">Terms of service</ModalTitle><ModalClose aria-label="Close" target="terms">×</ModalClose></ModalHeader>
{Array.from({ length: 30 }, (_, index) => <p key={index}>Clause {index + 1}: text that makes the document taller than the screen.</p>)}
<ModalFooter><ModalClose target="terms">Done</ModalClose></ModalFooter>
</ModalBody>
</ModalContent>
</>;
}Opening a modal from a modal
Render the child Modal inside the parent. It opens on top, Escape closes only the child, focus returns to the button that opened it, and the page is dimmed once.
import { Modal, ModalClose } from "@gunkin/uiify";
export default function Example() {
return (
<Modal
id="editor"
trigger="Edit profile"
title="Edit profile"
description="Unsaved changes are lost when you close."
>
<Modal
id="discard"
trigger="Discard changes"
title="Discard changes?"
description="This cannot be undone."
footer={<ModalClose target="discard">Keep editing</ModalClose>}
/>
</Modal>
);
}API
Modal
| Prop | Tipo | Predeterminado | Descripción |
|---|---|---|---|
id* | string | — | Id único de diálogo al que apuntan el activador y el botón de cierre, para que el diálogo se abra y se cierre antes de la hidratación. |
trigger* | ReactNode | — | Contenido del botón que abre el diálogo. |
title* | ReactNode | — | Encabezado renderizado dentro del diálogo que se usa como nombre accesible. |
description | ReactNode | — | Texto de apoyo renderizado bajo el título y referenciado por aria-describedby. |
children | ReactNode | — | Contenido del cuerpo, renderizado en el ModalBody desplazable. |
closeLabel | string | "Close" | Nombre accesible del ícono de cierre del encabezado. |
footer | ReactNode | — | Fila de acciones fijada bajo el cuerpo. Coloca aquí un ModalClose para añadir un botón inferior de cierre. |
size | "sm" | "md" | "lg" | "full" | "md" | Tamaño de ancho (24, 28, 40rem). full llena el área visible. Se escribe en data-size; reemplaza --uiify-modal-inline-size para usar un ancho personalizado. |
align | "center" | "start" | "center" | Posición vertical en tabletas y escritorio. En teléfonos se convierte en un panel inferior, salvo que size sea full. |
dismiss | "any" | "closerequest" | "none" | "any" | closedby: any añade toques en el fondo, closerequest permite solo Escape y none, solo tus controles. Safari no tiene closedby, por lo que Escape siempre cierra; UIEnhance gestiona los toques en el fondo (any) y el deslizamiento del panel (distinto de none). |
backdrop | "opaque" | "blur" | "none" | "opaque" | Tratamiento del fondo. Se escribe en data-backdrop. |
className | string | — | Clase aplicada al elemento dialog. |
ModalTrigger
| Prop | Tipo | Predeterminado | Descripción |
|---|---|---|---|
target* | string | — | Id del ModalContent que abre este botón; el navegador lo resuelve al hacer clic, por lo que no se ejecuta JavaScript nuestro. |
children | ReactNode | — | Contenido del botón activador. |
as | TAs & NativeButtonAs<TAs> | "button" | Componente que pasa props y ref a un botón nativo; se rechazan otras etiquetas porque los comandos invoker necesitan un botón real. |
ModalContent
| Prop | Tipo | Predeterminado | Descripción |
|---|---|---|---|
id* | string | — | Id único proporcionado por el consumidor, referenciado por el activador y el destino de cierre. |
size | "sm" | "md" | "lg" | "full" | "md" | Tamaño de ancho (24, 28, 40rem). full llena el área visible. Se escribe en data-size; reemplaza --uiify-modal-inline-size para usar un ancho personalizado. |
align | "center" | "start" | "center" | Posición vertical en tabletas y escritorio. En teléfonos se convierte en un panel inferior, salvo que size sea full. |
dismiss | "any" | "closerequest" | "none" | "any" | closedby: any añade toques en el fondo, closerequest permite solo Escape y none, solo tus controles. Safari no tiene closedby, por lo que Escape siempre cierra; UIEnhance gestiona los toques en el fondo (any) y el deslizamiento del panel (distinto de none). |
backdrop | "opaque" | "blur" | "none" | "opaque" | Tratamiento del fondo. Se escribe en data-backdrop. |
children | ReactNode | — | Contenido del diálogo. |
ModalClose
| Prop | Tipo | Predeterminado | Descripción |
|---|---|---|---|
target* | string | — | Id del ModalContent que cierra este botón. |
children | ReactNode | — | Contenido del botón de cierre. |
as | TAs & NativeButtonAs<TAs> | "button" | Componente que pasa props y ref a un botón nativo; se rechazan otras etiquetas porque los comandos invoker necesitan un botón real. |
ModalHeader
| Prop | Tipo | Predeterminado | Descripción |
|---|---|---|---|
children | ReactNode | — | Fila superior fija que contiene el título, la descripción y el control de cierre. |
ModalTitle
| Prop | Tipo | Predeterminado | Descripción |
|---|---|---|---|
children | ReactNode | — | Nombre accesible del diálogo; referencia su id desde aria-labelledby. |
ModalDescription
| Prop | Tipo | Predeterminado | Descripción |
|---|---|---|---|
children | ReactNode | — | Texto de apoyo; referencia su id desde aria-describedby. |
ModalBody
| Prop | Tipo | Predeterminado | Descripción |
|---|---|---|---|
children | ReactNode | — | Región desplazable entre el encabezado y el pie. |
ModalFooter
| Prop | Tipo | Predeterminado | Descripción |
|---|---|---|---|
children | ReactNode | — | Fila de acciones fijada bajo el cuerpo. |
ModalGrabber
| Prop | Tipo | Predeterminado | Descripción |
|---|---|---|---|
children | ReactNode | — | Tirador de arrastre decorativo del panel móvil; el deslizamiento comienza aquí. |
ModalClientContent (@gunkin/uiify/components/modal/client)
| Prop | Tipo | Predeterminado | Descripción |
|---|---|---|---|
id* | string | — | Id único proporcionado por el consumidor, referenciado por el activador y el cierre. |
open | boolean | — | Estado de apertura controlado, que determina el estado después de la hidratación. |
defaultOpen | boolean | — | Estado inicial no controlado, aplicado con showModal() después de la hidratación; el marcado del servidor permanece cerrado. |
onOpenChange | (open: boolean) => void | — | Se dispara una vez por cambio nativo o programático con el nuevo estado, incluida una vez al montar si el navegador lo abrió antes de la hidratación. |
onOpenChangeComplete | (open: boolean) => void | — | Se dispara una vez por transición de apertura o cierre al terminar sus transiciones CSS, o de inmediato si no hay ninguna, incluida la transición defaultOpen tras la hidratación; nunca si el diálogo no cambió. |
size | "sm" | "md" | "lg" | "full" | "md" | Tamaño de ancho (24, 28, 40rem). full llena el área visible. Se escribe en data-size; reemplaza --uiify-modal-inline-size para usar un ancho personalizado. |
align | "center" | "start" | "center" | Posición vertical en tabletas y escritorio. En teléfonos se convierte en un panel inferior, salvo que size sea full. |
dismiss | "any" | "closerequest" | "none" | "any" | closedby: any añade toques en el fondo, closerequest permite solo Escape y none, solo tus controles. Safari no tiene closedby, por lo que Escape siempre cierra; UIEnhance gestiona los toques en el fondo (any) y el deslizamiento del panel (distinto de none). |
backdrop | "opaque" | "blur" | "none" | "opaque" | Tratamiento del fondo. Se escribe en data-backdrop. |
children | ReactNode | — | Contenido del diálogo. |
Teclado
| Tecla | Acción |
|---|---|
| Escape | Cerrar (solo el modal superior si hay anidación) |
| Tab | Recorrer el foco dentro del modal |
| Shift+Tab | Invertir el orden de tabulación |
Accesibilidad
<dialog> nativo que abre un invoker command="show-modal" antes de ejecutar JavaScript. El navegador gestiona la modalidad, la contención del foco, Escape y la devolución del foco; el cierre usa request-close para que onCancel pueda impedirlo. El ícono de cierre del encabezado siempre está presente. En teléfonos, el modal es un panel inferior; deslizarlo hacia abajo y tocar el fondo son atajos, nunca la única salida.
Puntos de estilo
data-uiify-modaldata-part="trigger|content|header|title|description|body|footer|grabber|close"data-size="sm|lg|full"data-align="start"data-backdrop="blur|none"--uiify-trigger-open (inside [data-uiify-modal][data-part="root"])--uiify-modal-inline-size (dialog width)