Last active
December 11, 2025 19:27
-
-
Save nicksheffield/89e30ea528b938dbdea9ff3c2db7152b to your computer and use it in GitHub Desktop.
Imperative modals for react using shadcn
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { openModal, type ModalProps } from '@/components/Modal' | |
| import { | |
| DialogContent, | |
| DialogHeader, | |
| DialogTitle, | |
| } from '@/components/ui/dialog' | |
| type MyModalProps = { | |
| name: string | |
| message: string | |
| } | |
| // this function that opens the modal. You can use it wherever you want. | |
| export const openMyModal = (props: MyModalProps) => { | |
| openModal({ | |
| render: (close) => { | |
| return <MyModal close={close} {...props} /> | |
| }, | |
| }) | |
| } | |
| const MyModal = ({ name, message, close }: ModalProps<MyModalProps>) => { | |
| return ( | |
| <DialogContent> | |
| <DialogHeader> | |
| <DialogTitle>My Modal</DialogTitle> | |
| </DialogHeader> | |
| <div> | |
| <h1>Hello {name}</h1> | |
| <p>{message}</p> | |
| </div> | |
| {/* this is optional, clicking the backdrop will close the modal, and the ModalHeader has its own x button that also works */} | |
| <button onClick={close}>Close</button> | |
| </DialogContent> | |
| ) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 'use client' | |
| import { Dialog } from '@/components/ui/dialog' | |
| import { ReactNode, useEffect, useState } from 'react' | |
| type ModalDef = { | |
| id: string | |
| render: ReactNode | |
| onClose?: () => void | |
| } | |
| // since we only have one modal provider per app, | |
| // we can hoist some functions up out of the provider to use globally | |
| const container: { | |
| addModal: (def: ModalDef) => void | |
| removeModal: (id: string) => void | |
| } = { | |
| addModal: () => {}, | |
| removeModal: () => {}, | |
| } | |
| export type ModalCloseFn = () => void | |
| export type ModalProps<T extends Record<string, unknown>> = T & { | |
| close: ModalCloseFn | |
| } | |
| export const openModal = (x: { | |
| onClose?: () => void | |
| render: (close: ModalCloseFn) => ReactNode | |
| }) => { | |
| const id = crypto.randomUUID() | |
| const close = () => { | |
| container.removeModal(id) | |
| } | |
| container.addModal({ | |
| id, | |
| render: x.render(close), | |
| onClose: x.onClose, | |
| }) | |
| } | |
| export const ModalProvider = () => { | |
| const [defs, setDefs] = useState<ModalDef[]>([]) | |
| const [closing, setClosing] = useState<string[]>([]) | |
| useEffect(() => { | |
| container.addModal = (def: ModalDef) => { | |
| setDefs((d) => [...d, def]) | |
| } | |
| container.removeModal = (id: string) => { | |
| setClosing((c) => [...c, id]) | |
| defs.find((x) => x.id === id)?.onClose?.() | |
| const newDefs = defs.filter((x) => { | |
| return x.id !== id | |
| }) | |
| setDefs(newDefs) | |
| setTimeout(() => { | |
| setClosing((c) => c.filter((x) => x !== id)) | |
| document.body.style.pointerEvents = 'auto' | |
| }, 500) // set this to the duration of your modal exit animation | |
| } | |
| }, [defs]) | |
| return ( | |
| <div id="modal-provider"> | |
| {defs.map((def) => ( | |
| <Dialog | |
| key={def.id} | |
| open={!closing.includes(def.id)} | |
| onOpenChange={(val) => { | |
| if (!val) container.removeModal(def.id) | |
| }} | |
| > | |
| {def.render} | |
| </Dialog> | |
| ))} | |
| </div> | |
| ) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment