Skip to main content
Nexus Design System
GitHub

Dialog

Dialog opens a modal for a task that interrupts the page. The user can dismiss it without making a choice.

  • If the user must confirm or cancel a consequential action, use AlertDialog.
  • For a panel at the screen edge, such as settings or filters, use Sheet.
  • For a mobile-style panel with drag-to-dismiss, use Drawer.
'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Dialog,
  DialogBody,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/nxds/components/dialog/dialog';

export default function DialogDefault() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="outline">Open Dialog</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Dialog Title</DialogTitle>
          <DialogDescription>
            This is a basic dialog with a title and description.
          </DialogDescription>
        </DialogHeader>
        <DialogBody>
          <p className="nx:typography-body-default nx:text-foreground">
            Dialog content goes here. You can add any content you need.
          </p>
        </DialogBody>
        <DialogFooter>
          <DialogClose asChild>
            <Button variant="outline">Cancel</Button>
          </DialogClose>
          <Button>Save</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

This example also needs: Button.

Installation

npx nxds-cli@latest add dialog

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  Dialog,
  DialogBody,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/nxds/components/dialog/dialog';

Composition

Dialog
├── DialogTrigger
└── DialogContent
    ├── DialogHeader
    │   ├── DialogTitle
    │   └── DialogDescription
    ├── DialogBody
    └── DialogFooter
        └── DialogClose

Examples

With description

Add a DialogDescription and a short form to edit a profile.

'use client';

import * as React from 'react';

import { Button } from '@/components/nxds/components/button/button';
import {
  Dialog,
  DialogBody,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/nxds/components/dialog/dialog';
import { Input } from '@/components/nxds/components/input/input';
import { Label } from '@/components/nxds/components/label/label';

export default function DialogWithDescription() {
  const nameId = React.useId();
  const usernameId = React.useId();

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button>Edit Profile</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Edit Profile</DialogTitle>
          <DialogDescription>
            Make changes to your profile here. Click save when you&apos;re done.
          </DialogDescription>
        </DialogHeader>
        <DialogBody className="nx:grid nx:gap-4 nx:py-4">
          <div className="nx:grid nx:grid-cols-4 nx:items-center nx:gap-4">
            <Label htmlFor={nameId} className="nx:justify-end">
              Name
            </Label>
            <Input
              id={nameId}
              defaultValue="John Doe"
              className="nx:col-span-3"
            />
          </div>
          <div className="nx:grid nx:grid-cols-4 nx:items-center nx:gap-4">
            <Label htmlFor={usernameId} className="nx:justify-end">
              Username
            </Label>
            <Input
              id={usernameId}
              defaultValue="@johndoe"
              className="nx:col-span-3"
            />
          </div>
        </DialogBody>
        <DialogFooter>
          <Button type="submit">Save changes</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

This example also needs: Input, Label.

Scrollable content

Long DialogBody content scrolls while the header and footer stay in place.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Dialog,
  DialogBody,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/nxds/components/dialog/dialog';

export default function DialogScrollableContent() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="outline">Terms & Conditions</Button>
      </DialogTrigger>
      <DialogContent className="nx:max-h-[300px]">
        <DialogHeader>
          <DialogTitle>Terms of Service</DialogTitle>
          <DialogDescription>
            Please read the following terms carefully.
          </DialogDescription>
        </DialogHeader>
        <DialogBody className="nx:space-y-4 nx:typography-label-default nx:text-foreground">
          {Array.from({ length: 10 }).map((_, i) => (
            <p key={i}>
              Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
              eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
              enim ad minim veniam, quis nostrud exercitation ullamco laboris
              nisi ut aliquip ex ea commodo consequat.
            </p>
          ))}
        </DialogBody>
        <DialogFooter>
          <DialogClose asChild>
            <Button variant="outline">Decline</Button>
          </DialogClose>
          <Button>Accept</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

Prop driven content

Pass title, description and body to DialogContent instead of composing the header and body.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogFooter,
  DialogTrigger,
} from '@/components/nxds/components/dialog/dialog';

export default function DialogPropDrivenContent() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="outline">Open invite dialog</Button>
      </DialogTrigger>
      <DialogContent
        title="Invite teammate"
        description="Send an invitation to a teammate and assign their first workspace role."
        body={
          <p className="nx:typography-body-default nx:text-foreground">
            The invitation expires in seven days and can be revoked from team
            settings.
          </p>
        }
      >
        <DialogFooter>
          <DialogClose asChild>
            <Button variant="outline">Cancel</Button>
          </DialogClose>
          <Button>Send invite</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

Props

Dialog

Dialog adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogBody

DialogBody adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogClose

DialogClose adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogContent

DialogContent props
PropTypeDefaultDescription
bodyReactNodeNo default documented

Content for a generated DialogBody, rendered before children. Ignored when a DialogBody child is present.

descriptionReactNodeNo default documented

Description for a generated header. Ignored when a DialogHeader child is present.

showCloseButtonbooleantrue

Shows the close button in the top-right corner.

titleReactNodeNo default documented

Title for a generated header. Ignored when DialogHeader is a direct child or inside a Fragment.

DialogDescription

DialogDescription adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogFooter adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogHeader

DialogHeader adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogOverlay

DialogOverlay adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogPortal

DialogPortal adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogTitle

DialogTitle adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogTrigger

DialogTrigger adds no props of its own; it accepts the props of the element or primitive it wraps.

Dialog opens a modal for a task that interrupts the page. The user can dismiss it without making a choice. It renders into document.body, traps focus, and returns focus to the trigger when it closes.

<script setup lang="ts">
import { Button } from '@/components/button';
import {
  Dialog,
  DialogBody,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/dialog';
</script>

<template>
  <Dialog>
    <DialogTrigger as-child>
      <Button variant="outline">Open Dialog</Button>
    </DialogTrigger>
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Dialog Title</DialogTitle>
        <DialogDescription
          >This is a basic dialog with a title and
          description.</DialogDescription
        >
      </DialogHeader>
      <DialogBody>
        <p class="nx:typography-body-default nx:text-foreground">
          Dialog content goes here. You can add any content you need.
        </p>
      </DialogBody>
      <DialogFooter>
        <DialogClose as-child>
          <Button variant="outline">Cancel</Button>
        </DialogClose>
        <Button>Save</Button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
</template>

This example also needs: Button.

Installation

  1. Install the following dependencies:

    npm install @phosphor-icons/vue@^2.2.1 @vueuse/core@^14.1.0 class-variance-authority@^0.7.1 reka-ui@^2.11.0
    
  2. Copy and paste the following code into your project.

    components/dialog/DialogBody.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { overlayBodyClassName } from '../overlay-layout/overlay-layout';
    
    export interface DialogBodyProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<DialogBodyProps>();
    </script>
    
    <template>
      <div
        data-slot="dialog-body"
        tabindex="0"
        :class="cn(overlayBodyClassName, props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/dialog/DialogContent.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      DialogClose,
      DialogContent,
      type DialogContentEmits,
      type DialogContentProps as RekaDialogContentProps,
      DialogPortal,
      useForwardPropsEmits,
    } from 'reka-ui';
    import { Fragment, type HTMLAttributes, type VNode } from 'vue';
    
    import { IconX } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    import {
      overlayCloseButtonClassName,
      overlayContentVariants,
    } from '../overlay-layout/overlay-layout';
    
    import DialogBody from './DialogBody.vue';
    import DialogDescription from './DialogDescription.vue';
    import DialogHeader from './DialogHeader.vue';
    import DialogOverlay from './DialogOverlay.vue';
    import DialogTitle from './DialogTitle.vue';
    
    export interface DialogContentProps extends RekaDialogContentProps {
      /** Shows the close button in the top-right corner. */
      showCloseButton?: boolean;
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = withDefaults(defineProps<DialogContentProps>(), {
      showCloseButton: true,
    });
    const emits = defineEmits<DialogContentEmits>();
    const slots = defineSlots<{
      default?(): VNode[];
      /**
       * Title for a generated header. Ignored when `DialogHeader` is a direct
       * child or inside a Fragment.
       */
      title?(): VNode[];
      /**
       * Description for a generated header. Ignored when a `DialogHeader` child is
       * present.
       */
      description?(): VNode[];
      /**
       * Content for a generated `DialogBody`, rendered before the default slot.
       * Ignored when a `DialogBody` child is present.
       */
      body?(): VNode[];
    }>();
    
    const delegated = reactiveOmit(props, 'class', 'showCloseButton');
    const forwarded = useForwardPropsEmits(delegated, emits);
    
    function containsComposedSlot(
      nodes: VNode[] | undefined,
      part: unknown
    ): boolean {
      return (nodes ?? []).some(
        (node) =>
          node.type === part ||
          (node.type === Fragment &&
            Array.isArray(node.children) &&
            containsComposedSlot(node.children as VNode[], part))
      );
    }
    
    /** Which parts DialogContent generates. The template binds it once per render, so the header and body checks share one read of the default slot. */
    function generatedParts() {
      const children = slots.default?.();
      return {
        header:
          (slots.title !== undefined || slots.description !== undefined) &&
          !containsComposedSlot(children, DialogHeader),
        body:
          slots.body !== undefined && !containsComposedSlot(children, DialogBody),
      };
    }
    </script>
    
    <template>
      <DialogPortal>
        <DialogOverlay />
        <DialogContent
          v-bind="{ ...$attrs, ...forwarded }"
          data-slot="dialog-content"
          data-variant="default"
          data-orientation="horizontal"
          :class="cn(overlayContentVariants(), props.class)"
        >
          <template v-for="(generated, index) in [generatedParts()]" :key="index">
            <DialogHeader v-if="generated.header">
              <DialogTitle v-if="slots.title"><slot name="title" /></DialogTitle>
              <DialogDescription v-if="slots.description">
                <slot name="description" />
              </DialogDescription>
            </DialogHeader>
            <DialogBody v-if="generated.body"><slot name="body" /></DialogBody>
          </template>
          <slot />
          <DialogClose
            v-if="showCloseButton"
            data-slot="dialog-close-button"
            :class="overlayCloseButtonClassName"
          >
            <IconX class="nx:size-4" />
            <span class="nx:sr-only">Close</span>
          </DialogClose>
        </DialogContent>
      </DialogPortal>
    </template>
    
    components/dialog/DialogDescription.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      DialogDescription,
      type DialogDescriptionProps as RekaDialogDescriptionProps,
      useForwardProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface DialogDescriptionProps extends RekaDialogDescriptionProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<DialogDescriptionProps>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <DialogDescription
        v-bind="forwarded"
        data-slot="dialog-description"
        :class="
          cn(
            'nx:m-0 nx:typography-body-default nx:text-muted-foreground',
            props.class
          )
        "
      >
        <slot />
      </DialogDescription>
    </template>
    
    components/dialog/DialogFooter.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface DialogFooterProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<DialogFooterProps>();
    </script>
    
    <template>
      <div
        data-slot="dialog-footer"
        data-orientation="horizontal"
        :class="
          cn(
            'nx:flex nx:shrink-0 nx:flex-col-reverse nx:px-6 nx:sm:flex-row nx:sm:justify-end nx:sm:gap-2',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/dialog/DialogHeader.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { overlayHeaderVariants } from '../overlay-layout/overlay-layout';
    
    export interface DialogHeaderProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<DialogHeaderProps>();
    </script>
    
    <template>
      <div
        data-slot="dialog-header"
        data-variant="default"
        :class="cn(overlayHeaderVariants({ variant: 'default' }), props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/dialog/DialogOverlay.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      DialogOverlay,
      type DialogOverlayProps as RekaDialogOverlayProps,
      useForwardProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { overlayScrimVariants } from '../overlay-layout/overlay-layout';
    
    export interface DialogOverlayProps extends RekaDialogOverlayProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<DialogOverlayProps>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <DialogOverlay
        v-bind="forwarded"
        data-slot="dialog-overlay"
        :class="cn(overlayScrimVariants(), props.class)"
      />
    </template>
    
    components/dialog/DialogTitle.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      DialogTitle,
      type DialogTitleProps as RekaDialogTitleProps,
      useForwardProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface DialogTitleProps extends RekaDialogTitleProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<DialogTitleProps>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <DialogTitle
        v-bind="forwarded"
        data-slot="dialog-title"
        :class="cn('nx:m-0 nx:typography-heading-xsmall', props.class)"
      >
        <slot />
      </DialogTitle>
    </template>
    
    components/dialog/index.ts
    export { default as DialogBody, type DialogBodyProps } from './DialogBody.vue';
    export {
      default as DialogContent,
      type DialogContentProps,
    } from './DialogContent.vue';
    export {
      default as DialogDescription,
      type DialogDescriptionProps,
    } from './DialogDescription.vue';
    export {
      default as DialogFooter,
      type DialogFooterProps,
    } from './DialogFooter.vue';
    export {
      default as DialogHeader,
      type DialogHeaderProps,
    } from './DialogHeader.vue';
    export {
      default as DialogOverlay,
      type DialogOverlayProps,
    } from './DialogOverlay.vue';
    export {
      default as DialogTitle,
      type DialogTitleProps,
    } from './DialogTitle.vue';
    export {
      DialogRoot as Dialog,
      DialogClose,
      type DialogCloseProps,
      DialogPortal,
      type DialogPortalProps,
      type DialogRootProps as DialogProps,
      DialogTrigger,
      type DialogTriggerProps,
    } from 'reka-ui';
    
    components/overlay-layout/overlay-layout.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    /**
     * Popover-family surfaces share the translucent/blurred recipe; Tooltip uses a
     * solid carve-out because its small, brief text appears over arbitrary content.
     */
    export const popoverSurfaceClassName = [
      'nx:rounded-md',
      'nx:border-default',
      'nx:border-border-default',
      'nx:bg-popover-alpha',
      'nx:backdrop-blur-md',
      'nx:text-popover-foreground',
      'nx:typography-body-default',
      'nx:shadow-lg',
    ].join(' ');
    
    export const tooltipSurfaceClassName = [
      'nx:rounded-md',
      'nx:border-default',
      'nx:border-border-default',
      'nx:bg-popover',
      'nx:text-popover-foreground',
      'nx:typography-body-default',
      'nx:shadow-lg',
    ].join(' ');
    
    export const navigationMenuInlinePopoverSurfaceClassName = [
      'nx:group-data-[viewport=false]/navigation-menu:rounded-md',
      'nx:group-data-[viewport=false]/navigation-menu:border-default',
      'nx:group-data-[viewport=false]/navigation-menu:border-border-default',
      'nx:group-data-[viewport=false]/navigation-menu:bg-popover-alpha',
      'nx:group-data-[viewport=false]/navigation-menu:backdrop-blur-md',
      'nx:group-data-[viewport=false]/navigation-menu:text-popover-foreground',
      'nx:group-data-[viewport=false]/navigation-menu:shadow-lg',
    ].join(' ');
    
    export const navigationMenuInlinePopoverTransitionClassName = [
      'nx:group-data-[viewport=false]/navigation-menu:transition-[opacity,scale]',
      'nx:group-data-[viewport=false]/navigation-menu:duration-fast',
      'nx:group-data-[viewport=false]/navigation-menu:ease-move',
      'nx:group-data-[viewport=false]/navigation-menu:data-[state=closed]:scale-95',
      'nx:group-data-[viewport=false]/navigation-menu:data-[state=closed]:opacity-0',
      'nx:group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-overlay-presence-exit',
    ].join(' ');
    
    // `animate-overlay-presence-exit` is a non-visual "presence bridge": Reka's
    // Presence only waits on `animationName`, not `transitionend`, so this inert
    // animation keeps a closing overlay mounted while the opacity/scale/translate
    // TRANSITIONS below run the visible exit. It animates an unread custom property (see
    // generateMotionUtilitiesCSS in @nxds/core), never a transitioned property, so it
    // cannot override those transitions.
    const overlayPresenceExitClassName =
      'nx:data-[state=closed]:animate-overlay-presence-exit';
    
    export const overlayFloatingTransitionClassName = [
      'nx:transition-[opacity,scale]',
      'nx:duration-fast',
      'nx:ease-move',
      'nx:data-[state=closed]:scale-95',
      'nx:data-[state=closed]:opacity-0',
      overlayPresenceExitClassName,
    ].join(' ');
    
    const overlayContentTransitionClassName = [
      'nx:transition-[opacity,scale]',
      'nx:duration-default',
      'nx:ease-move',
      'nx:data-[state=closed]:scale-95',
      'nx:data-[state=closed]:opacity-0',
      overlayPresenceExitClassName,
    ].join(' ');
    
    export const overlayScrimTransitionClassName = [
      'nx:transition-opacity',
      'nx:duration-default',
      'nx:ease-move',
      'nx:data-[state=closed]:opacity-0',
      overlayPresenceExitClassName,
    ].join(' ');
    
    export const overlayPanelTransitionClassName = [
      'nx:transition-[translate]',
      'nx:duration-slow',
      'nx:ease-move',
      overlayPresenceExitClassName,
    ].join(' ');
    
    export const overlayContentVariants = cva(
      [
        // Centred with margins, not a translate: a transform would make this the
        // containing block for fixed-position popups a dialog holds.
        'nx:fixed nx:inset-0 nx:z-modal nx:m-auto nx:flex nx:h-fit nx:w-full nx:max-w-lg nx:flex-col',
        'nx:max-h-[calc(100svh-2rem)] nx:overflow-hidden',
        'nx:gap-4 nx:border-default nx:border-border-default nx:bg-container nx:py-6 nx:shadow-lg',
        overlayContentTransitionClassName,
        'nx:sm:rounded-lg',
      ].join(' ')
    );
    
    export const overlayScrimVariants = cva(
      [
        'nx:fixed nx:inset-0 nx:z-modal nx:bg-overlay',
        overlayScrimTransitionClassName,
      ].join(' ')
    );
    
    export const overlayHeaderVariants = cva(
      'nx:flex nx:shrink-0 nx:flex-col nx:gap-1 nx:px-6',
      {
        variants: {
          variant: {
            default: 'nx:text-center nx:sm:text-left',
            center: 'nx:items-center nx:text-center',
          },
        },
        defaultVariants: {
          variant: 'default',
        },
      }
    );
    
    export const overlayBodyClassName = [
      'nx:min-h-0',
      'nx:overflow-y-auto',
      'nx:px-6',
      'nx:focus-visible:outline-2',
      'nx:focus-visible:outline-focus-default',
      'nx:focus-visible:[outline-offset:-2px]',
    ].join(' ');
    
    export const overlayFooterVariants = cva(
      'nx:flex nx:shrink-0 nx:gap-2 nx:px-6',
      {
        variants: {
          orientation: {
            horizontal:
              'nx:flex-col nx:sm:flex-row nx:sm:items-center nx:sm:justify-end',
            vertical: 'nx:flex-col nx:items-stretch nx:*:w-full',
          },
        },
        defaultVariants: {
          orientation: 'horizontal',
        },
      }
    );
    
    export const overlayCloseButtonClassName = [
      'nx:absolute nx:right-6 nx:top-6 nx:rounded-sm nx:border-0 nx:bg-transparent nx:p-1 nx:text-muted-foreground-subtle',
      'nx:transition-control',
      'nx:hover:bg-container-hover nx:hover:text-foreground',
      'nx:focus-visible:bg-container-hover nx:focus-visible:text-foreground',
      'nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default',
      'nx:disabled:pointer-events-none',
    ].join(' ');
    
    export type OverlayHeaderVariants = VariantProps<typeof overlayHeaderVariants>;
    
    export type OverlayVariant = NonNullable<
      VariantProps<typeof overlayHeaderVariants>['variant']
    >;
    
    export type OverlayButtonOrientation = NonNullable<
      VariantProps<typeof overlayFooterVariants>['orientation']
    >;
    
    export type OverlayLayoutContextValue = {
      variant: OverlayVariant;
      buttonOrientation: OverlayButtonOrientation;
    };
    
    export const defaultOverlayLayout: OverlayLayoutContextValue = {
      variant: 'default',
      buttonOrientation: 'horizontal',
    };
    
    export function resolveOverlayButtonOrientation(
      variant: OverlayVariant
    ): OverlayButtonOrientation {
      return variant === 'center' ? 'vertical' : 'horizontal';
    }
    
    lib/icons.ts
    /**
     * Internal Icons
     *
     * This barrel file re-exports icons used internally by the design system.
     * These are hardcoded icons for component states (loading, validation, navigation).
     *
     * User-facing icons should be passed via props by consumers.
     *
     * This set draws Phosphor. `nxds init --icons` installs the same names drawn
     * by another library.
     */
    import { PhCircle } from '@phosphor-icons/vue';
    import { type FunctionalComponent, h } from 'vue';
    
    // Loading states
    export { PhCircleNotch as IconLoader2 } from '@phosphor-icons/vue';
    
    // Navigation
    export {
      PhArrowDown as IconArrowDown,
      PhArrowLeft as IconArrowLeft,
      PhArrowRight as IconArrowRight,
      PhArrowUp as IconArrowUp,
      PhCaretDown as IconChevronDown,
      PhCaretLeft as IconChevronLeft,
      PhCaretRight as IconChevronRight,
      PhCaretUp as IconChevronUp,
      PhDotsThree as IconDots,
      PhSidebarSimple as IconLayoutSidebar,
      PhCaretUpDown as IconSelector,
    } from '@phosphor-icons/vue';
    
    // Actions
    export {
      PhCheck as IconCheck,
      PhCopy as IconCopy,
      PhMinus as IconMinus,
      PhPencilSimple as IconPencil,
      PhPlus as IconPlus,
      PhX as IconX,
    } from '@phosphor-icons/vue';
    
    export const IconCircleFilled: FunctionalComponent = (_props, { attrs }) =>
      h(PhCircle, { weight: 'fill', ...attrs });
    
    // Filter field types
    export {
      PhCalendarBlank as IconCalendar,
      PhHash as IconHash,
      PhTextAa as IconLetterCase,
      PhListBullets as IconList,
    } from '@phosphor-icons/vue';
    
    // Search / input
    export { PhMagnifyingGlass as IconSearch } from '@phosphor-icons/vue';
    
    // Alerts/Status
    export {
      PhWarningCircle as IconAlertCircle,
      PhWarning as IconAlertTriangle,
      PhCheckCircle as IconCircleCheck,
      PhInfo as IconInfoCircle,
    } from '@phosphor-icons/vue';
    
    // Layout
    export {
      PhDotsSixVertical as IconGripVertical,
      PhPalette as IconPalette,
    } from '@phosphor-icons/vue';
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/dialog';

Examples

With description

Add a DialogDescription and a short form to edit a profile.

<script setup lang="ts">
import { useId } from 'vue';

import { Button } from '@/components/button';
import {
  Dialog,
  DialogBody,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/dialog';

const nameId = useId();
const usernameId = useId();
</script>

<template>
  <Dialog>
    <DialogTrigger as-child>
      <Button>Edit Profile</Button>
    </DialogTrigger>
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Edit Profile</DialogTitle>
        <DialogDescription
          >Make changes to your profile here. Click save when you're
          done.</DialogDescription
        >
      </DialogHeader>
      <DialogBody class="nx:grid nx:gap-4 nx:py-4">
        <div class="nx:grid nx:grid-cols-4 nx:items-center nx:gap-4">
          <label :for="nameId" class="nx:justify-end">Name</label>
          <input :id="nameId" value="John Doe" class="nx:col-span-3" />
        </div>
        <div class="nx:grid nx:grid-cols-4 nx:items-center nx:gap-4">
          <label :for="usernameId" class="nx:justify-end">Username</label>
          <input :id="usernameId" value="@johndoe" class="nx:col-span-3" />
        </div>
      </DialogBody>
      <DialogFooter>
        <Button type="submit">Save changes</Button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
</template>

Scrollable content

Long DialogBody content scrolls while the header and footer stay in place.

<script setup lang="ts">
import { Button } from '@/components/button';
import {
  Dialog,
  DialogBody,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/dialog';
</script>

<template>
  <Dialog>
    <DialogTrigger as-child>
      <Button variant="outline">Terms & Conditions</Button>
    </DialogTrigger>
    <DialogContent class="nx:max-h-[300px]">
      <DialogHeader>
        <DialogTitle>Terms of Service</DialogTitle>
        <DialogDescription
          >Please read the following terms carefully.</DialogDescription
        >
      </DialogHeader>
      <DialogBody
        class="nx:space-y-4 nx:typography-label-default nx:text-foreground"
      >
        <p v-for="i in 10" :key="i">
          Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
          eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
          minim veniam, quis nostrud exercitation ullamco laboris nisi ut
          aliquip ex ea commodo consequat.
        </p>
      </DialogBody>
      <DialogFooter>
        <DialogClose as-child>
          <Button variant="outline">Decline</Button>
        </DialogClose>
        <Button>Accept</Button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
</template>

Prop driven content

Fill the #title, #description and #body slots of DialogContent instead of composing the header and body.

<script setup lang="ts">
import { Button } from '@/components/button';
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogFooter,
  DialogTrigger,
} from '@/components/dialog';
</script>

<template>
  <Dialog>
    <DialogTrigger as-child>
      <Button variant="outline">Open invite dialog</Button>
    </DialogTrigger>
    <DialogContent>
      <template #title>Invite teammate</template>
      <template #description
        >Send an invitation to a teammate and assign their first workspace
        role.</template
      >
      <template #body>
        <p class="nx:typography-body-default nx:text-foreground">
          The invitation expires in seven days and can be revoked from team
          settings.
        </p>
      </template>
      <DialogFooter>
        <DialogClose as-child>
          <Button variant="outline">Cancel</Button>
        </DialogClose>
        <Button>Send invite</Button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
</template>

Props

DialogBody

DialogBody adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogContent

DialogContent props
PropTypeDefaultDescription
showCloseButtonbooleantrue

Shows the close button in the top-right corner.

DialogDescription

DialogDescription adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogFooter adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogHeader

DialogHeader adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogOverlay

DialogOverlay adds no props of its own; it accepts the props of the element or primitive it wraps.

DialogTitle

DialogTitle adds no props of its own; it accepts the props of the element or primitive it wraps.