Skip to main content
Nexus Design System
GitHub

Sheet

Sheet slides a panel in from one edge of the viewport. Use it for settings, filters, navigation or supporting forms. For a centered task use Dialog, for a forced choice use AlertDialog, and for drag-to-dismiss use Drawer.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/nxds/components/sheet/sheet';

export default function SheetDefault() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button variant="outline">Open Sheet</Button>
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Sheet Title</SheetTitle>
          <SheetDescription>
            This panel slides in from the right edge by default.
          </SheetDescription>
        </SheetHeader>
        <SheetBody>
          <p className="nx:typography-body-default nx:text-foreground">
            Sheet content goes here. You can add any content you need.
          </p>
        </SheetBody>
        <SheetFooter>
          <SheetClose asChild>
            <Button variant="outline">Cancel</Button>
          </SheetClose>
          <Button>Save</Button>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  );
}

This example also needs: Button.

Installation

npx nxds-cli@latest add sheet

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

Usage

import {
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/nxds/components/sheet/sheet';

Composition

Sheet
├── SheetTrigger
└── SheetContent
    ├── SheetHeader
    │   ├── SheetTitle
    │   └── SheetDescription
    ├── SheetBody
    └── SheetFooter
        └── SheetClose

Examples

With form

Put a short form in the sheet with Save and Cancel actions in the footer.

'use client';

import * as React from 'react';

import { Button } from '@/components/nxds/components/button/button';
import { Input } from '@/components/nxds/components/input/input';
import { Label } from '@/components/nxds/components/label/label';
import {
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/nxds/components/sheet/sheet';

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

  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button>Edit Profile</Button>
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Edit Profile</SheetTitle>
          <SheetDescription>
            Update your profile here, then save when you are done.
          </SheetDescription>
        </SheetHeader>
        <SheetBody className="nx:grid nx:gap-4">
          <div className="nx:grid nx:gap-1.5">
            <Label htmlFor={nameId}>Name</Label>
            <Input id={nameId} defaultValue="John Doe" />
          </div>
          <div className="nx:grid nx:gap-1.5">
            <Label htmlFor={usernameId}>Username</Label>
            <Input id={usernameId} defaultValue="@johndoe" />
          </div>
        </SheetBody>
        <SheetFooter>
          <SheetClose asChild>
            <Button variant="outline">Cancel</Button>
          </SheetClose>
          <Button type="submit">Save changes</Button>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  );
}

This example also needs: Input, Label.

Scrollable content

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

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/nxds/components/sheet/sheet';

export default function SheetScrollableContent() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button variant="outline">Terms & Conditions</Button>
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Terms of Service</SheetTitle>
          <SheetDescription>
            Please read the following terms carefully.
          </SheetDescription>
        </SheetHeader>
        <SheetBody className="nx:space-y-4 nx:typography-label-default nx:text-foreground">
          {Array.from({ length: 12 }).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>
          ))}
        </SheetBody>
        <SheetFooter>
          <SheetClose asChild>
            <Button variant="outline">Decline</Button>
          </SheetClose>
          <Button>Accept</Button>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  );
}

All variants

Open a sheet from each of the four edges.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/nxds/components/sheet/sheet';

const SIDES = ['top', 'right', 'bottom', 'left'] as const;

export default function SheetAllVariants() {
  return (
    <div className="nx:flex nx:flex-wrap nx:gap-4">
      {SIDES.map((side) => (
        <Sheet key={side}>
          <SheetTrigger asChild>
            <Button variant="outline" className="nx:capitalize">
              {side}
            </Button>
          </SheetTrigger>
          <SheetContent side={side}>
            <SheetHeader>
              <SheetTitle className="nx:capitalize">{side} sheet</SheetTitle>
              <SheetDescription>
                Slides in from the {side} edge.
              </SheetDescription>
            </SheetHeader>
            <SheetFooter>
              <SheetClose asChild>
                <Button>Close</Button>
              </SheetClose>
            </SheetFooter>
          </SheetContent>
        </Sheet>
      ))}
    </div>
  );
}

Props

Sheet

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

SheetBody

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

SheetClose

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

SheetContent

SheetContent props
PropTypeDefaultDescription
showCloseButtonbooleantrue

Shows the close button in the top-right corner.

side"bottom" | "left" | "right" | "top" | null"right"No description

SheetDescription

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

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

SheetHeader

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

SheetOverlay

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

SheetPortal

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

SheetTitle

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

SheetTrigger

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

Sheet slides a panel in from one edge of the viewport. Use it for settings, filters, navigation or supporting forms. For a centered task use Dialog, for a forced choice use AlertDialog, and for drag-to-dismiss use Drawer.

<script setup lang="ts">
import { Button } from '@/components/button';
import {
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/sheet';
</script>

<template>
  <Sheet>
    <SheetTrigger as-child>
      <Button variant="outline">Open Sheet</Button>
    </SheetTrigger>
    <SheetContent>
      <SheetHeader>
        <SheetTitle>Sheet Title</SheetTitle>
        <SheetDescription
          >This panel slides in from the right edge by
          default.</SheetDescription
        >
      </SheetHeader>
      <SheetBody>
        <p class="nx:typography-body-default nx:text-foreground">
          Sheet content goes here. You can add any content you need.
        </p>
      </SheetBody>
      <SheetFooter>
        <SheetClose as-child>
          <Button variant="outline">Cancel</Button>
        </SheetClose>
        <Button>Save</Button>
      </SheetFooter>
    </SheetContent>
  </Sheet>
</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/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';
    }
    
    components/sheet/SheetBody.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { overlayBodyClassName } from '../overlay-layout/overlay-layout';
    
    export interface SheetBodyProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<SheetBodyProps>();
    </script>
    
    <template>
      <div
        data-slot="sheet-body"
        tabindex="0"
        :class="cn(overlayBodyClassName, props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/sheet/SheetContent.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      DialogClose,
      DialogContent,
      type DialogContentEmits,
      type DialogContentProps,
      DialogPortal,
      useForwardPropsEmits,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { IconX } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    import { overlayCloseButtonClassName } from '../overlay-layout/overlay-layout';
    
    import SheetOverlay from './SheetOverlay.vue';
    
    import { type SheetContentVariants, sheetContentVariants } from '.';
    
    export interface SheetContentProps extends DialogContentProps {
      side?: SheetContentVariants['side'];
      /**
       * Shows the close button in the top-right corner.
       */
      showCloseButton?: boolean;
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = withDefaults(defineProps<SheetContentProps>(), {
      side: 'right',
      showCloseButton: true,
    });
    const emits = defineEmits<DialogContentEmits>();
    
    const delegated = reactiveOmit(props, 'class', 'side', 'showCloseButton');
    const forwarded = useForwardPropsEmits(delegated, emits);
    </script>
    
    <template>
      <DialogPortal>
        <SheetOverlay />
        <DialogContent
          data-slot="sheet-content"
          :data-side="side"
          :class="cn(sheetContentVariants({ side }), props.class)"
          v-bind="{ ...$attrs, ...forwarded }"
        >
          <slot />
          <DialogClose
            v-if="showCloseButton"
            data-slot="sheet-close-button"
            :class="overlayCloseButtonClassName"
          >
            <IconX class="nx:size-4" />
            <span class="nx:sr-only">Close</span>
          </DialogClose>
        </DialogContent>
      </DialogPortal>
    </template>
    
    components/sheet/SheetDescription.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      DialogDescription,
      type DialogDescriptionProps,
      useForwardProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface SheetDescriptionProps extends DialogDescriptionProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<SheetDescriptionProps>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <DialogDescription
        v-bind="forwarded"
        data-slot="sheet-description"
        :class="
          cn(
            'nx:m-0 nx:typography-body-default nx:text-muted-foreground',
            props.class
          )
        "
      >
        <slot />
      </DialogDescription>
    </template>
    
    components/sheet/SheetFooter.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface SheetFooterProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<SheetFooterProps>();
    </script>
    
    <template>
      <div
        data-slot="sheet-footer"
        :class="
          cn(
            'nx:mt-auto nx:flex nx:shrink-0 nx:flex-col nx:gap-2 nx:p-6',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/sheet/SheetHeader.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface SheetHeaderProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<SheetHeaderProps>();
    </script>
    
    <template>
      <div
        data-slot="sheet-header"
        :class="cn('nx:flex nx:shrink-0 nx:flex-col nx:gap-1 nx:p-6', props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/sheet/SheetOverlay.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      DialogOverlay,
      type DialogOverlayProps,
      useForwardProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { overlayScrimTransitionClassName } from '../overlay-layout/overlay-layout';
    
    export interface SheetOverlayProps extends DialogOverlayProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<SheetOverlayProps>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <DialogOverlay
        v-bind="forwarded"
        data-slot="sheet-overlay"
        :class="
          cn(
            'nx:fixed nx:inset-0 nx:z-modal nx:bg-overlay',
            overlayScrimTransitionClassName,
            props.class
          )
        "
      />
    </template>
    
    components/sheet/SheetTitle.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import { DialogTitle, type DialogTitleProps, useForwardProps } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface SheetTitleProps extends DialogTitleProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<SheetTitleProps>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <DialogTitle
        v-bind="forwarded"
        data-slot="sheet-title"
        :class="cn('nx:m-0 nx:typography-heading-xsmall', props.class)"
      >
        <slot />
      </DialogTitle>
    </template>
    
    components/sheet/index.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    import { cn } from '../../lib/utils';
    import { overlayPanelTransitionClassName } from '../overlay-layout/overlay-layout';
    
    export { default as SheetBody, type SheetBodyProps } from './SheetBody.vue';
    export {
      default as SheetContent,
      type SheetContentProps,
    } from './SheetContent.vue';
    export {
      default as SheetDescription,
      type SheetDescriptionProps,
    } from './SheetDescription.vue';
    export {
      default as SheetFooter,
      type SheetFooterProps,
    } from './SheetFooter.vue';
    export {
      default as SheetHeader,
      type SheetHeaderProps,
    } from './SheetHeader.vue';
    export {
      default as SheetOverlay,
      type SheetOverlayProps,
    } from './SheetOverlay.vue';
    export { default as SheetTitle, type SheetTitleProps } from './SheetTitle.vue';
    export {
      DialogRoot as Sheet,
      DialogClose as SheetClose,
      type DialogCloseProps as SheetCloseProps,
      DialogPortal as SheetPortal,
      type DialogPortalProps as SheetPortalProps,
      type DialogRootProps as SheetProps,
      DialogTrigger as SheetTrigger,
      type DialogTriggerProps as SheetTriggerProps,
    } from 'reka-ui';
    
    /**
     * Per-side positioning and slide motion for the sheet panel. Each side pins the
     * panel to an edge, and a closed panel translates 100% past that edge.
     */
    export const sheetContentVariants = cva(
      cn(
        'nx:fixed nx:z-modal nx:flex nx:flex-col nx:overflow-hidden',
        'nx:bg-container nx:shadow-lg',
        overlayPanelTransitionClassName
      ),
      {
        variants: {
          side: {
            top: cn(
              'nx:inset-x-0 nx:top-0 nx:h-auto nx:max-h-svh nx:border-b-default nx:border-border-default',
              'nx:data-[state=closed]:-translate-y-full'
            ),
            bottom: cn(
              'nx:inset-x-0 nx:bottom-0 nx:h-auto nx:max-h-svh nx:border-t-default nx:border-border-default',
              'nx:data-[state=closed]:translate-y-full'
            ),
            left: cn(
              'nx:left-0 nx:top-0 nx:h-svh nx:w-3/4 nx:border-r-default nx:border-border-default nx:sm:max-w-sm',
              'nx:data-[state=closed]:-translate-x-full'
            ),
            right: cn(
              'nx:right-0 nx:top-0 nx:h-svh nx:w-3/4 nx:border-l-default nx:border-border-default nx:sm:max-w-sm',
              'nx:data-[state=closed]:translate-x-full'
            ),
          },
        },
        defaultVariants: {
          side: 'right',
        },
      }
    );
    
    export type SheetContentVariants = VariantProps<typeof sheetContentVariants>;
    
    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 { Sheet, SheetBody, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/sheet';

Examples

With form

Put a short form in the sheet with Save and Cancel actions in the footer.

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

import { Button } from '@/components/button';
import { Input } from '@/components/input';
import { Label } from '@/components/label';
import {
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/sheet';

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

<template>
  <Sheet>
    <SheetTrigger as-child>
      <Button>Edit Profile</Button>
    </SheetTrigger>
    <SheetContent>
      <SheetHeader>
        <SheetTitle>Edit Profile</SheetTitle>
        <SheetDescription
          >Update your profile here, then save when you are
          done.</SheetDescription
        >
      </SheetHeader>
      <SheetBody class="nx:grid nx:gap-4">
        <div class="nx:grid nx:gap-1.5">
          <Label :for="nameId">Name</Label>
          <Input :id="nameId" default-value="John Doe" />
        </div>
        <div class="nx:grid nx:gap-1.5">
          <Label :for="usernameId">Username</Label>
          <Input :id="usernameId" default-value="@johndoe" />
        </div>
      </SheetBody>
      <SheetFooter>
        <SheetClose as-child>
          <Button variant="outline">Cancel</Button>
        </SheetClose>
        <Button type="submit">Save changes</Button>
      </SheetFooter>
    </SheetContent>
  </Sheet>
</template>

This example also needs: Input, Label.

Scrollable content

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

<script setup lang="ts">
import { Button } from '@/components/button';
import {
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/sheet';
</script>

<template>
  <Sheet>
    <SheetTrigger as-child>
      <Button variant="outline">Terms & Conditions</Button>
    </SheetTrigger>
    <SheetContent>
      <SheetHeader>
        <SheetTitle>Terms of Service</SheetTitle>
        <SheetDescription
          >Please read the following terms carefully.</SheetDescription
        >
      </SheetHeader>
      <SheetBody
        class="nx:space-y-4 nx:typography-label-default nx:text-foreground"
      >
        <p v-for="i in 12" :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>
      </SheetBody>
      <SheetFooter>
        <SheetClose as-child>
          <Button variant="outline">Decline</Button>
        </SheetClose>
        <Button>Accept</Button>
      </SheetFooter>
    </SheetContent>
  </Sheet>
</template>

All variants

Open a sheet from each of the four edges.

<script setup lang="ts">
import { Button } from '@/components/button';
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/sheet';

const sides = ['top', 'right', 'bottom', 'left'] as const;
</script>

<template>
  <div class="nx:flex nx:flex-wrap nx:gap-4">
    <Sheet v-for="side in sides" :key="side">
      <SheetTrigger as-child>
        <Button variant="outline" class="nx:capitalize">{{ side }}</Button>
      </SheetTrigger>
      <SheetContent :side="side">
        <SheetHeader>
          <SheetTitle class="nx:capitalize">{{ side }} sheet</SheetTitle>
          <SheetDescription
            >Slides in from the {{ side }} edge.</SheetDescription
          >
        </SheetHeader>
        <SheetFooter>
          <SheetClose as-child>
            <Button>Close</Button>
          </SheetClose>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  </div>
</template>

Props

SheetBody

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

SheetContent

SheetContent props
PropTypeDefaultDescription
showCloseButtonbooleantrue

Shows the close button in the top-right corner.

side"bottom" | "top" | "right" | "left" | null"right"No description

SheetDescription

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

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

SheetHeader

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

SheetOverlay

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

SheetTitle

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