Modal
Модальный диалог на основе нативного элемента dialog.
- Браузеры
- Chrome 123+Firefox 121+Safari 17.5+
- Уровень
- 0
- Сжатый размер
- 944 B
- Лимит принудительной компоновки
- 0 ms
- Лимит axe
- 0
- Совместим с SSR
- Да
- На основе
- elements/dialog
- Публичный контракт
- Тестовый стенд
Песочница
Modal
Закрыт
Управляемый
Изменения: , состояние:
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;
}
}
}Примеры
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
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
id* | string | — | Уникальный идентификатор диалога, на который нацелены триггер и кнопка закрытия, чтобы диалог открывался и закрывался до гидратации. |
trigger* | ReactNode | — | Содержимое кнопки открытия диалога. |
title* | ReactNode | — | Заголовок внутри диалога, используемый как его доступное название. |
description | ReactNode | — | Поясняющий текст под заголовком, на который ссылается aria-describedby. |
children | ReactNode | — | Основное содержимое в прокручиваемом ModalBody. |
closeLabel | string | "Close" | Доступное название иконки закрытия в шапке. |
footer | ReactNode | — | Строка действий, закреплённая под содержимым. Поместите сюда ModalClose для нижней кнопки Close. |
size | "sm" | "md" | "lg" | "full" | "md" | Ступень ширины (24, 28, 40rem). full заполняет область просмотра. Записывается в data-size; для собственной ширины переопределите --uiify-modal-inline-size. |
align | "center" | "start" | "center" | Вертикальное положение на планшете и компьютере. На телефонах используется нижняя панель, если size не равен full. |
dismiss | "any" | "closerequest" | "none" | "any" | closedby: any добавляет закрытие касанием фона, closerequest разрешает только Escape, none — только ваши элементы управления. Safari не поддерживает closedby, поэтому Escape всегда закрывает; UIEnhance обрабатывает касания фона (any) и свайп панели (кроме none). |
backdrop | "opaque" | "blur" | "none" | "opaque" | Оформление фона. Записывается в data-backdrop. |
className | string | — | Класс элемента диалога. |
ModalTrigger
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
target* | string | — | Идентификатор ModalContent, открываемого этой кнопкой; браузер разрешает его при щелчке, поэтому JavaScript библиотеки не выполняется. |
children | ReactNode | — | Содержимое кнопки триггера. |
as | TAs & NativeButtonAs<TAs> | "button" | Компонент, передающий свойства и ref нативной кнопке; другие теги отклоняются, поскольку командам invoker нужна настоящая кнопка. |
ModalContent
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
id* | string | — | Уникальный идентификатор, заданный потребителем; на него ссылаются триггер и цель закрытия. |
size | "sm" | "md" | "lg" | "full" | "md" | Ступень ширины (24, 28, 40rem). full заполняет область просмотра. Записывается в data-size; для собственной ширины переопределите --uiify-modal-inline-size. |
align | "center" | "start" | "center" | Вертикальное положение на планшете и компьютере. На телефонах используется нижняя панель, если size не равен full. |
dismiss | "any" | "closerequest" | "none" | "any" | closedby: any добавляет закрытие касанием фона, closerequest разрешает только Escape, none — только ваши элементы управления. Safari не поддерживает closedby, поэтому Escape всегда закрывает; UIEnhance обрабатывает касания фона (any) и свайп панели (кроме none). |
backdrop | "opaque" | "blur" | "none" | "opaque" | Оформление фона. Записывается в data-backdrop. |
children | ReactNode | — | Содержимое диалога. |
ModalClose
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
target* | string | — | Идентификатор ModalContent, закрываемого этой кнопкой. |
children | ReactNode | — | Содержимое кнопки закрытия. |
as | TAs & NativeButtonAs<TAs> | "button" | Компонент, передающий свойства и ref нативной кнопке; другие теги отклоняются, поскольку командам invoker нужна настоящая кнопка. |
ModalHeader
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
children | ReactNode | — | Закреплённая верхняя строка с заголовком, описанием и элементом закрытия. |
ModalTitle
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
children | ReactNode | — | Доступное название диалога; ссылайтесь на его id из aria-labelledby. |
ModalDescription
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
children | ReactNode | — | Поясняющий текст; ссылайтесь на его id из aria-describedby. |
ModalBody
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
children | ReactNode | — | Прокручиваемая область между шапкой и подвалом. |
ModalFooter
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
children | ReactNode | — | Строка действий, закреплённая под содержимым. |
ModalGrabber
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
children | ReactNode | — | Декоративная ручка перетаскивания мобильной панели; свайп начинается здесь. |
ModalClientContent (@gunkin/uiify/components/modal/client)
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
id* | string | — | Уникальный идентификатор, заданный потребителем; на него ссылаются триггер и закрытие. |
open | boolean | — | Управляемое состояние открытия, определяющее поведение после гидратации. |
defaultOpen | boolean | — | Неуправляемое начальное состояние, применяемое через showModal() после гидратации; серверная разметка остаётся закрытой. |
onOpenChange | (open: boolean) => void | — | Срабатывает один раз на каждое нативное или программное изменение с новым состоянием, в том числе при монтировании, если браузер открыл диалог до гидратации. |
onOpenChangeComplete | (open: boolean) => void | — | Срабатывает один раз на каждый переход открытия или закрытия после завершения переходов CSS либо сразу, если их нет, включая переход defaultOpen после гидратации; никогда не срабатывает для неизменившегося диалога. |
size | "sm" | "md" | "lg" | "full" | "md" | Ступень ширины (24, 28, 40rem). full заполняет область просмотра. Записывается в data-size; для собственной ширины переопределите --uiify-modal-inline-size. |
align | "center" | "start" | "center" | Вертикальное положение на планшете и компьютере. На телефонах используется нижняя панель, если size не равен full. |
dismiss | "any" | "closerequest" | "none" | "any" | closedby: any добавляет закрытие касанием фона, closerequest разрешает только Escape, none — только ваши элементы управления. Safari не поддерживает closedby, поэтому Escape всегда закрывает; UIEnhance обрабатывает касания фона (any) и свайп панели (кроме none). |
backdrop | "opaque" | "blur" | "none" | "opaque" | Оформление фона. Записывается в data-backdrop. |
children | ReactNode | — | Содержимое диалога. |
Клавиатура
| Клавиша | Действие |
|---|---|
| Escape | Закрыть (при вложенности — только верхнее модальное окно) |
| Tab | Циклически перемещать фокус внутри модального окна |
| Shift+Tab | Обойти элементы в обратном порядке табуляции |
Доступность
Нативный <dialog> открывается триггером command="show-modal" до выполнения JavaScript. Браузер обеспечивает модальность, удержание фокуса, Escape и возврат фокуса; закрытие использует request-close, поэтому onCancel может его отменить. Иконка закрытия в шапке присутствует всегда. На телефонах модальное окно становится нижней панелью; свайп вниз и касание фона — дополнительные способы закрытия, но никогда не единственные.
Привязка стилей
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)