Dialog
Modal dialog.
Code
Copy into src/components/ph/ .
dialog.css
.ph-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100dvh - 64px);
margin: auto;
padding: 0;
border: 1px solid var(--foreground);
border-radius: 0;
background: var(--background);
color: var(--foreground);
opacity: 0;
scale: 0.97;
transition:
opacity 150ms var(--ph-ease),
scale 150ms var(--ph-ease),
overlay 150ms allow-discrete,
display 150ms allow-discrete;
}
.ph-dialog[open] {
opacity: 1;
scale: 1;
}
@starting-style {
.ph-dialog[open] {
opacity: 0;
scale: 0.97;
}
}
.ph-dialog::backdrop {
background: rgb(0 0 0 / 0);
transition:
background 150ms var(--ph-ease),
overlay 150ms allow-discrete,
display 150ms allow-discrete;
}
.ph-dialog[open]::backdrop {
background: rgb(0 0 0 / 0.4);
}
@starting-style {
.ph-dialog[open]::backdrop {
background: rgb(0 0 0 / 0);
}
}
@media (prefers-reduced-motion: reduce) {
.ph-dialog,
.ph-dialog::backdrop {
transition: none;
}
}
Dialog.astro
---
import Icon from "./Icon.astro";
import "./dialog.css";
type Props = {
id: string;
title: string;
class?: string;
};
const { id, title, class: className } = Astro.props;
---
<dialog id={id} data-ph-dialog aria-labelledby={`${id}-title`} class:list={["ph-dialog", className]}>
<div class="flex items-center justify-between gap-4 border-b border-foreground px-5 py-3">
<h2 id={`${id}-title`} class="ph-subheadline text-[13px]">{title}</h2>
<button type="button" data-ph-dialog-close aria-label="Close" class="-mr-1.5 cursor-pointer p-1.5 hover:bg-foreground/[0.08]">
<Icon name="times" class="size-3.5" />
</button>
</div>
<div class="flex flex-col gap-3 px-5 py-4 leading-[1.5]"><slot /></div>
</dialog>
<script>
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const opener = target.closest<HTMLElement>("[data-ph-dialog-open]");
if (opener) {
document.querySelector<HTMLDialogElement>(`#${opener.dataset.phDialogOpen}`)?.showModal();
return;
}
if (target.closest("[data-ph-dialog-close]")) {
target.closest("dialog")?.close();
return;
}
if (target instanceof HTMLDialogElement && target.matches("[data-ph-dialog]")) target.close();
});
</script>
Copy into components/ph/ (or src/components/ph/).
dialog.css
.ph-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100dvh - 64px);
margin: auto;
padding: 0;
border: 1px solid var(--foreground);
border-radius: 0;
background: var(--background);
color: var(--foreground);
opacity: 0;
scale: 0.97;
transition:
opacity 150ms var(--ph-ease),
scale 150ms var(--ph-ease),
overlay 150ms allow-discrete,
display 150ms allow-discrete;
}
.ph-dialog[open] {
opacity: 1;
scale: 1;
}
@starting-style {
.ph-dialog[open] {
opacity: 0;
scale: 0.97;
}
}
.ph-dialog::backdrop {
background: rgb(0 0 0 / 0);
transition:
background 150ms var(--ph-ease),
overlay 150ms allow-discrete,
display 150ms allow-discrete;
}
.ph-dialog[open]::backdrop {
background: rgb(0 0 0 / 0.4);
}
@starting-style {
.ph-dialog[open]::backdrop {
background: rgb(0 0 0 / 0);
}
}
@media (prefers-reduced-motion: reduce) {
.ph-dialog,
.ph-dialog::backdrop {
transition: none;
}
}
dialog.tsx
"use client";
import { useEffect, useId, useRef, type ReactNode } from "react";
import { cn } from "./cn";
import { Icon } from "./icon";
import "./dialog.css";
type DialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
children: ReactNode;
className?: string;
};
export function Dialog({ open, onOpenChange, title, children, className }: DialogProps) {
const ref = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
const el = ref.current;
if (!el) return;
if (open && !el.open) el.showModal();
if (!open && el.open) el.close();
}, [open]);
return (
<dialog
ref={ref}
aria-labelledby={titleId}
className={cn("ph-dialog", className)}
onClose={() => onOpenChange(false)}
onClick={(e) => {
if (e.target === e.currentTarget) onOpenChange(false);
}}
>
<div className="flex items-center justify-between gap-4 border-b border-foreground px-5 py-3">
<h2 id={titleId} className="ph-subheadline text-[13px]">
{title}
</h2>
<button type="button" aria-label="Close" onClick={() => onOpenChange(false)} className="-mr-1.5 cursor-pointer p-1.5 hover:bg-foreground/[0.08]">
<Icon name="times" className="size-3.5" />
</button>
</div>
<div className="flex flex-col gap-3 px-5 py-4 leading-[1.5]">{children}</div>
</dialog>
);
}
cn.ts
export const cn = (...parts: (string | false | null | undefined)[]) => parts.filter(Boolean).join(" ");