Modal
A centered dialog over a dimmed overlay that closes on Escape, overlay click or an explicit close control.
A centred dialog with a header, a body and a footer. It is the settings-panel shape from the client, so a form inside it looks like it belongs there.
newt-modal"use client"
import * as React from "react"
import { Button } from "@/registry/bases/newt/ui/button"
import {
Modal,
ModalBody,
ModalClose,
ModalFooter,
ModalHeader,
} from "@/registry/bases/newt/ui/modal"
export default function ModalDemo() {
const [open, setOpen] = React.useState(false)
return (
<>
<Button variant="primary" onClick={() => setOpen(true)}>
Open modal
</Button>
<Modal open={open} onOpenChange={setOpen}>
<ModalHeader>Modal title</ModalHeader>
<ModalBody>Modal body content goes here.</ModalBody>
<ModalFooter>
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button variant="danger" onClick={() => setOpen(false)}>
Confirm
</Button>
</ModalFooter>
</Modal>
</>
)
}<script setup lang="ts">
import { ref } from "vue"
import { Button } from "~~/registry/bases/newt/ui/button"
import {
Modal,
ModalBody,
ModalClose,
ModalFooter,
ModalHeader,
} from "~~/registry/bases/newt/ui/modal"
const open = ref(false)
</script>
<template>
<Button variant="primary" @click="open = true">Open modal</Button>
<Modal v-model:open="open">
<ModalHeader>Modal title</ModalHeader>
<ModalBody>Modal body content goes here.</ModalBody>
<ModalFooter>
<ModalClose>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button variant="danger" @click="open = false">Confirm</Button>
</ModalFooter>
</Modal>
</template>Installation
pnpm dlx newtui@latest add modalnpx newtui@latest add modalyarn dlx newtui@latest add modalbunx newtui@latest add modalAdd the components it builds on
Copy the source into your project
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
type ModalContextValue = {
close: () => void
titleId: string
}
const ModalContext = React.createContext<ModalContextValue | null>(null)
function useModalContext() {
const ctx = React.useContext(ModalContext)
if (!ctx) {
throw new Error("Modal components must be rendered inside <Modal>")
}
return ctx
}
export interface ModalProps extends React.ComponentProps<"div"> {
/** Whether the modal is shown. */
open?: boolean
/** Called with `false` when the overlay is clicked, Escape is pressed or a ModalClose is activated. */
onOpenChange?: (open: boolean) => void
}
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
({ className, open = false, onOpenChange, children, ...props }, ref) => {
const titleId = React.useId()
const close = React.useCallback(() => onOpenChange?.(false), [onOpenChange])
React.useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") close()
}
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
}, [open, close])
if (!open) return null
return (
<ModalContext.Provider value={{ close, titleId }}>
<div
data-state="open"
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60"
onClick={(e) => {
if (e.target === e.currentTarget) close()
}}
>
<div
ref={ref}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={cn(
"w-[440px] max-w-[90vw] rounded-lg bg-newt-bg-elevated shadow-elevation-high",
className
)}
{...props}
>
{children}
</div>
</div>
</ModalContext.Provider>
)
}
)
Modal.displayName = "Modal"
const ModalHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
const { titleId } = useModalContext()
return (
<div
ref={ref}
id={titleId}
className={cn("px-4 pt-4 text-xl font-semibold", className)}
{...props}
/>
)
})
ModalHeader.displayName = "ModalHeader"
const ModalBody = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"p-4 text-sm leading-5 text-newt-text-secondary",
className
)}
{...props}
/>
)
)
ModalBody.displayName = "ModalBody"
const ModalFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"flex justify-end gap-2 rounded-b-lg bg-newt-bg-surface p-4",
className
)}
{...props}
/>
))
ModalFooter.displayName = "ModalFooter"
export interface ModalCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Render the child element instead of a button, injecting the click handler. */
asChild?: boolean
}
/**
* Closes the modal when activated. Wrap a Button with `asChild` to keep its styling:
* `<ModalClose asChild><Button variant="secondary">Cancel</Button></ModalClose>`
*/
const ModalClose = React.forwardRef<HTMLButtonElement, ModalCloseProps>(
({ asChild, onClick, children, ...props }, ref) => {
const { close } = useModalContext()
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e)
if (!e.defaultPrevented) close()
}
if (asChild && React.isValidElement(children)) {
// SAFETY: asChild renders an arbitrary child element and must inject an
// onClick handler; React.isValidElement only narrows to
// ReactElement<unknown>, so the caller-supplied element's prop shape
// can't be verified from ReactNode alone.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const child = children as React.ReactElement<{
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
}>
return React.cloneElement(child, {
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
child.props.onClick?.(e)
handleClick(e)
},
})
}
return (
<button ref={ref} type="button" onClick={handleClick} {...props}>
{children}
</button>
)
}
)
ModalClose.displayName = "ModalClose"
export { Modal, ModalHeader, ModalBody, ModalFooter, ModalClose }<script setup lang="ts">
import {
onBeforeUnmount,
onMounted,
provide,
useId,
watch,
type HTMLAttributes,
} from "vue"
import { cn } from "@/lib/utils"
import { MODAL_CONTEXT_KEY } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
/** Use `v-model:open` to control visibility. */
const open = defineModel<boolean>("open", { default: false })
const titleId = useId()
function close() {
open.value = false
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") close()
}
function onOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) close()
}
// `onMounted` only ever runs in the browser (Vue skips it during SSR), so
// registering the watcher from inside it keeps `document` access client-only
// without probing for it at runtime.
onMounted(() => {
watch(
open,
(isOpen) => {
if (isOpen) document.addEventListener("keydown", onKeyDown)
else document.removeEventListener("keydown", onKeyDown)
},
{ immediate: true }
)
})
onBeforeUnmount(() => {
document.removeEventListener("keydown", onKeyDown)
})
provide(MODAL_CONTEXT_KEY, { close, titleId })
</script>
<template>
<div
v-if="open"
data-state="open"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60"
@click="onOverlayClick"
>
<div
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:class="
cn(
'w-[440px] max-w-[90vw] rounded-lg bg-newt-bg-elevated shadow-elevation-high',
props.class
)
"
>
<slot />
</div>
</div>
</template><script setup lang="ts">
import { inject, type HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { MODAL_CONTEXT_KEY } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const ctx = inject(MODAL_CONTEXT_KEY, null)
</script>
<template>
<div
:id="ctx?.titleId"
:class="cn('px-4 pt-4 text-xl font-semibold', props.class)"
>
<slot />
</div>
</template><script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="cn('p-4 text-sm leading-5 text-newt-text-secondary', props.class)"
>
<slot />
</div>
</template><script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="
cn(
'flex justify-end gap-2 rounded-b-lg bg-newt-bg-surface p-4',
props.class
)
"
>
<slot />
</div>
</template><script setup lang="ts">
import { inject } from "vue"
import { MODAL_CONTEXT_KEY } from "."
/**
* Closes the enclosing Modal when clicked. Renders a wrapper `<span>` so it can
* wrap any element: `<ModalClose><Button variant="secondary">Cancel</Button></ModalClose>`
*/
const ctx = inject(MODAL_CONTEXT_KEY, null)
function onClick() {
ctx?.close()
}
</script>
<template>
<span class="contents" @click="onClick">
<slot />
</span>
</template>import type { InjectionKey } from "vue"
export { default as Modal } from "./Modal.vue"
export { default as ModalHeader } from "./ModalHeader.vue"
export { default as ModalBody } from "./ModalBody.vue"
export { default as ModalFooter } from "./ModalFooter.vue"
export { default as ModalClose } from "./ModalClose.vue"
export interface ModalContext {
close: () => void
titleId: string
}
export const MODAL_CONTEXT_KEY: InjectionKey<ModalContext> =
Symbol("newt-modal")Update the import paths
The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.
Usage
import { Modal } from "@/components/ui/modal"
export function Example() {
return <Modal />
}<script setup lang="ts">
import { Modal, ModalHeader, ModalBody, ModalFooter, ModalClose } from "@/components/ui/modal"
</script>
<template>
<Modal />
</template>API reference
Modal
open?boolean
Whether the dialog is shown.
booleanonOpenChange?(open: boolean) => void
Called when the dialog asks to close — the backdrop, the close button, or Escape.
(open: boolean) => voidModalHeader
children?ReactNode
The title. Becomes the dialog's accessible name.
ReactNodeModalFooter
children?ReactNode
Actions, laid out end-aligned.
ReactNodeEvery part also accepts the native attributes of the element it renders, and a className that is merged rather than replaced.
Accessibility
The dialog carries role="dialog" and aria-modal="true", traps focus while it is open, returns focus to whatever opened it, and closes on Escape. The content behind it is inert. Give ModalHeader real text: it is what a screen reader announces when the dialog opens.
Tokens
Modal reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-modal.