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
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
id* | string | — | トリガーと閉じるボタンが対象とする、一意のdialog ID。ハイドレーション前にダイアログを開閉できます。 |
trigger* | ReactNode | — | ダイアログを開くボタンの内容。 |
title* | ReactNode | — | ダイアログ内に描画し、そのアクセシブルな名前に使う見出し。 |
description | ReactNode | — | タイトルの下に描画し、aria-describedbyから参照する補足テキスト。 |
children | ReactNode | — | スクロールするModalBody内に描画する本文。 |
closeLabel | string | "Close" | ヘッダーの閉じるアイコンのアクセシブルな名前。 |
footer | ReactNode | — | 本文の下に固定するアクション行。下部に閉じるボタンを置くには、ここにModalCloseを配置します。 |
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 | — | dialog要素に適用するクラス。 |
ModalTrigger
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
target* | string | — | このボタンが開くModalContentのID。クリック時にブラウザーが解決するため、ライブラリのJavaScriptは実行しません。 |
children | ReactNode | — | トリガーボタンの内容。 |
as | TAs & NativeButtonAs<TAs> | "button" | ネイティブのbuttonにpropsとrefを転送するコンポーネント。invokerコマンドには実際のbuttonが必要なため、他のタグは拒否します。 |
ModalContent
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
id* | string | — | 利用側が指定する一意のID。トリガーと閉じる操作の対象として参照します。 |
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
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
target* | string | — | このボタンが閉じるModalContentのID。 |
children | ReactNode | — | 閉じるボタンの内容。 |
as | TAs & NativeButtonAs<TAs> | "button" | ネイティブのbuttonにpropsとrefを転送するコンポーネント。invokerコマンドには実際のbuttonが必要なため、他のタグは拒否します。 |
ModalHeader
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
children | ReactNode | — | タイトル、説明、閉じるコントロールを保持する、上部の固定行。 |
ModalTitle
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
children | ReactNode | — | ダイアログのアクセシブルな名前。aria-labelledbyからそのIDを参照します。 |
ModalDescription
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
children | ReactNode | — | 補足テキスト。aria-describedbyからそのIDを参照します。 |
ModalBody
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
children | ReactNode | — | ヘッダーとフッターの間のスクロール領域。 |
ModalFooter
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
children | ReactNode | — | 本文の下に固定するアクション行。 |
ModalGrabber
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
children | ReactNode | — | モバイルシートの装飾用ドラッグハンドル。ここからスワイプを開始します。 |
ModalClientContent (@gunkin/uiify/components/modal/client)
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
id* | string | — | 利用側が指定する一意のID。トリガーと閉じる操作から参照します。 |
open | boolean | — | 制御される開閉状態。ハイドレーション後はこの値に従います。 |
defaultOpen | boolean | — | 非制御の初期状態。ハイドレーション後にshowModal()で適用します。サーバーマークアップは閉じたままです。 |
onOpenChange | (open: boolean) => void | — | ネイティブまたはプログラムによる変更ごとに、新しい状態で1回発火します。ハイドレーション前にブラウザーが開いた場合は、マウント時にも1回発火します。 |
onOpenChangeComplete | (open: boolean) => void | — | CSSトランジション完了後、なければ即座に、開閉の遷移ごとに1回発火します。ハイドレーション後の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>を、JavaScript実行前にcommand="show-modal"のinvokerで開きます。モーダル動作、フォーカスの封じ込め、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)