Skip to content

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.

Root class 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 modal
npx newtui@latest add modal
yarn dlx newtui@latest add modal
bunx newtui@latest add modal

Add 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

PropType
open?boolean

Whether the dialog is shown.

Typeboolean
onOpenChange?(open: boolean) => void

Called when the dialog asks to close — the backdrop, the close button, or Escape.

Type(open: boolean) => void

ModalHeader

PropType
children?ReactNode

The title. Becomes the dialog's accessible name.

TypeReactNode

ModalFooter

PropType
children?ReactNode

Actions, laid out end-aligned.

TypeReactNode

Every 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.