Sheet

A panel that slides in from an edge of the screen.
Installation
Expand

npx @left4code/cosmic-ui-cli@latest add sheet
 
Expand

This writes the component and everything it imports, then installs the packages it needs. Run npx @left4code/cosmic-ui-cli@latest initfirst if you haven't already (it detects Vue automatically).

Install the following dependencies:

pnpm
npm
yarn
bun
yarn add @zag-js/dialog @zag-js/vue

Built on the same Zag.js dialog machine as Dialog (there's no separate sheet machine) - the only difference is SheetContent positions itself against an edge and slides in from that direction instead of scaling in from the center, controlled by a side prop ("top" | "right" | "bottom" | "left", defaults to "right"). The plain border shown here is a placeholder; swap it for the Frame primitive later. Vue SFCs can't export multiple components from one file, so Sheet is a folder instead of a single file — copy all of it into your project.

components/ui/sheet/context.ts
Expand

import type { InjectionKey, ComputedRef } from "vue";
import type * as dialogMachine from "@zag-js/dialog";

type SheetApi = ComputedRef<ReturnType<typeof dialogMachine.connect>>;

const SheetApiKey: InjectionKey<SheetApi> = Symbol("sheet-api");

export { SheetApiKey };
export type { SheetApi };
 
Expand
components/ui/sheet/SheetRoot.vue
Expand

<script setup lang="ts">
import { computed, provide, useAttrs, useId } from "vue";
import { useMachine, normalizeProps } from "@zag-js/vue";
import * as dialogMachine from "@zag-js/dialog";
import { SheetApiKey } from "./context";

defineOptions({ inheritAttrs: false });
const rawAttrs = useAttrs() as Partial<dialogMachine.Props>;
const id = useId();

const service = useMachine(
  dialogMachine.machine,
  computed(() => ({ id, ...rawAttrs }))
);
const api = computed(() => dialogMachine.connect(service, normalizeProps));

provide(SheetApiKey, api);
</script>

<template>
  <slot />
</template>
 
Expand
components/ui/sheet/SheetTrigger.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import Button from "@/components/ui/button.vue";
import { SheetApiKey } from "./context";

defineProps<{ class?: string }>();

const injectedApi = inject(SheetApiKey);
if (!injectedApi) throw new Error("Sheet parts must be used within <SheetRoot>");
const api = injectedApi;
</script>

<template>
  <Button v-bind="api.getTriggerProps()" :class="$props.class">
    <slot />
  </Button>
</template>
 
Expand
components/ui/sheet/SheetContent.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import Portal from "@/components/ui/portal.vue";
import { usePresence } from "@/components/ui/presence";
import { SheetApiKey } from "./context";

type SheetSide = "top" | "right" | "bottom" | "left";

const { class: className, side = "right" } = defineProps<{
  class?: string;
  side?: SheetSide;
}>();

const injectedApi = inject(SheetApiKey);
if (!injectedApi) throw new Error("Sheet parts must be used within <SheetRoot>");
const api = injectedApi;

const backdrop = usePresence(() => api.value.open);
const content = usePresence(() => api.value.open);

// Each side needs its own edge, size, border, and slide direction - the rest
// of the class list (positioning strategy, animation timing, surface) stays
// the same across all four.
const sideClasses: Record<SheetSide, string> = {
  right:
    "inset-y-0 right-0 h-full w-full max-w-sm border-l border-primary/30 data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right",
  left: "inset-y-0 left-0 h-full w-full max-w-sm border-r border-primary/30 data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left",
  top: "inset-x-0 top-0 w-full border-b border-primary/30 data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
  bottom:
    "inset-x-0 bottom-0 w-full border-t border-primary/30 data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
};
</script>

<template>
  <Portal>
    <div
      v-bind="api.getBackdropProps()"
      :ref="backdrop.setNode"
      :hidden="!backdrop.present.value"
      class="fixed inset-0 bg-background/80 z-50 [&[data-state='open']]:animate-in [&[data-state='open']]:fade-in-0 [&[data-state='closed']]:animate-out [&[data-state='closed']]:fade-out-0"
    />
    <div v-bind="api.getPositionerProps()">
      <div
        v-bind="api.getContentProps()"
        :ref="content.setNode"
        :hidden="!content.present.value"
        :class="
          twMerge([
            'outline-none fixed z-50 p-6 bg-background overflow-y-auto',
            `[&[data-state='open']]:animate-in [&[data-state='open']]:duration-300`,
            `[&[data-state='closed']]:animate-out [&[data-state='closed']]:duration-250`,
            sideClasses[side],
            className,
          ])
        "
      >
        <slot />
      </div>
    </div>
  </Portal>
</template>
 
Expand
components/ui/sheet/SheetTitle.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import { SheetApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(SheetApiKey);
if (!injectedApi) throw new Error("Sheet parts must be used within <SheetRoot>");
const api = injectedApi;
</script>

<template>
  <div v-bind="api.getTitleProps()" :class="twMerge(['font-bold text-lg', className])">
    <slot />
  </div>
</template>
 
Expand
components/ui/sheet/SheetDescription.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import { SheetApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(SheetApiKey);
if (!injectedApi) throw new Error("Sheet parts must be used within <SheetRoot>");
const api = injectedApi;
</script>

<template>
  <div v-bind="api.getDescriptionProps()" :class="twMerge(['opacity-80 py-2', className])">
    <slot />
  </div>
</template>
 
Expand
components/ui/sheet/SheetCloseTrigger.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { X } from "@lucide/vue";
import { twMerge } from "tailwind-merge";
import { SheetApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(SheetApiKey);
if (!injectedApi) throw new Error("Sheet parts must be used within <SheetRoot>");
const api = injectedApi;
</script>

<template>
  <button
    v-bind="api.getCloseTriggerProps()"
    :class="twMerge(['absolute right-4 top-4 opacity-70 hover:opacity-100 cursor-pointer', className])"
  >
    <X class="size-4" />
  </button>
</template>
 
Expand
components/ui/sheet/index.ts
Expand

export { default as SheetRoot } from "./SheetRoot.vue";
export { default as SheetTrigger } from "./SheetTrigger.vue";
export { default as SheetContent } from "./SheetContent.vue";
export { default as SheetTitle } from "./SheetTitle.vue";
export { default as SheetDescription } from "./SheetDescription.vue";
export { default as SheetCloseTrigger } from "./SheetCloseTrigger.vue";
 
Expand

Update the import paths to match your project setup.

Usage
Expand

import {
  SheetRoot,
  SheetTrigger,
  SheetContent,
  SheetTitle,
  SheetDescription,
  SheetCloseTrigger,
} from "@/components/ui/sheet";
 
Expand
Expand

<SheetRoot>
  <SheetTrigger>Open</SheetTrigger>
  <SheetContent side="left">
    <SheetCloseTrigger />
    <SheetTitle>Menu</SheetTitle>
    <SheetDescription>Choose a destination.</SheetDescription>
  </SheetContent>
</SheetRoot>
 
Expand
Powered by synthetic caffeine · Deployed by Left4code · Signal traceable on GitHub.