+ )}
+
)
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
new file mode 100644
index 0000000..76dec8f
--- /dev/null
+++ b/src/components/Modal.tsx
@@ -0,0 +1,42 @@
+import { createSignal, onMount } from "solid-js"
+import type { JSX } from "solid-js"
+
+const DURATION = 200
+
+/**
+ * Overlay modal with an enter/exit animation. The children render-prop receives a `close`
+ * function that plays the exit animation before invoking `onClose` (so the caller can keep
+ * the modal mounted under a `` and still get an exit transition).
+ */
+export default function Modal(props: {
+ onClose: () => void
+ contentClass?: string
+ children: (close: () => void) => JSX.Element
+}) {
+ const [open, setOpen] = createSignal(false)
+
+ // Mount hidden, then flip to visible. A double rAF is required: a single frame fires
+ // before the browser paints the initial hidden state, so the change would coalesce into
+ // one paint and skip the enter transition. Waiting two frames guarantees the hidden state
+ // is painted first, so opacity/scale actually animate in.
+ onMount(() => requestAnimationFrame(() => requestAnimationFrame(() => setOpen(true))))
+
+ function close() {
+ setOpen(false)
+ setTimeout(props.onClose, DURATION)
+ }
+
+ return (
+