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 هنا لزر إغلاق سفلي. |
size | "sm" | "md" | "lg" | "full" | "md" | مستوى العرض (أربع وعشرون أو ثمان وعشرون أو أربعون وحدة rem). تملأ 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" | مكوّن يمرّر الخصائص والمرجع إلى زر أصلي؛ تُرفض الوسوم الأخرى لأن أوامر invoker تحتاج إلى زر حقيقي. |
ModalContent
| الخاصية | النوع | افتراضي | الوصف |
|---|---|---|---|
id* | string | — | معرّف فريد يوفّره المستهلك؛ يشير إليه زر التفعيل وهدف الإغلاق. |
size | "sm" | "md" | "lg" | "full" | "md" | مستوى العرض (أربع وعشرون أو ثمان وعشرون أو أربعون وحدة rem). تملأ 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" | مكوّن يمرّر الخصائص والمرجع إلى زر أصلي؛ تُرفض الوسوم الأخرى لأن أوامر invoker تحتاج إلى زر حقيقي. |
ModalHeader
| الخاصية | النوع | افتراضي | الوصف |
|---|---|---|---|
children | ReactNode | — | صف علوي ثابت يضم العنوان والوصف وعنصر تحكم الإغلاق. |
ModalTitle
| الخاصية | النوع | افتراضي | الوصف |
|---|---|---|---|
children | ReactNode | — | اسم مربع الحوار للتقنيات المساعدة؛ أشِر إلى معرّفه من aria-labelledby. |
ModalDescription
| الخاصية | النوع | افتراضي | الوصف |
|---|---|---|---|
children | ReactNode | — | نص مساعد؛ أشِر إلى معرّفه من 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" | مستوى العرض (أربع وعشرون أو ثمان وعشرون أو أربعون وحدة rem). تملأ 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 | عكس ترتيب Tab |
إمكانية الوصول
عنصر أصلي <dialog> يفتحه invoker بأمر 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)