# Sheet

> Side panel on the native dialog with a lit leading edge.

Category: Overlay. Docs: https://neonkit.heyaadi.com/components/sheet

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/sheet.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/sheet`.

npm dependencies: `motion`. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

Builds on: [Icons](https://neonkit.heyaadi.com/components/icons.md). The CLI installs them too.

## Usage

```tsx
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

<Sheet>
  <SheetTrigger asChild>
    <Button>Environment variables</Button>
  </SheetTrigger>
  <SheetContent side="right">
    <SheetHeader>
      <SheetTitle>Environment variables</SheetTitle>
      <SheetDescription>Changes apply to the next deployment.</SheetDescription>
    </SheetHeader>
  </SheetContent>
</Sheet>
```

## Examples

### Settings panel

A form in a right sheet. Control open to close it once the form submits.

```tsx
"use client";

import * as React from "react";
import { KeyRound, Plus, Trash2 } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet";

type Variable = { id: number; key: string; value: string };

const initial: Variable[] = [
  { id: 1, key: "DATABASE_URL", value: "postgres://acme@db.acme.dev:5432/main" },
  { id: 2, key: "REDIS_URL", value: "rediss://cache.acme.dev:6380" },
  { id: 3, key: "STRIPE_SECRET_KEY", value: "sk_live_51Hx…3f9a" },
];

const environments = ["Production", "Preview", "Development"];

export default function SheetDemo() {
  const [open, setOpen] = React.useState(false);
  const [variables, setVariables] = React.useState(initial);
  const [targets, setTargets] = React.useState(["Production", "Preview"]);
  const nextId = React.useRef(initial.length + 1);

  function update(id: number, patch: Partial<Variable>) {
    setVariables((current) => current.map((variable) => (variable.id === id ? { ...variable, ...patch } : variable)));
  }

  return (
    <Sheet open={open} onOpenChange={setOpen}>
      <SheetTrigger asChild>
        <Button>
          <KeyRound /> Environment variables
        </Button>
      </SheetTrigger>
      <SheetContent>
        <form
          className="flex min-h-full flex-col"
          onSubmit={(event) => {
            event.preventDefault();
            setOpen(false);
          }}
        >
          <SheetHeader>
            <SheetTitle>Environment variables</SheetTitle>
            <SheetDescription>
              Encrypted at rest for <span className="text-white/80">acme-web</span>. Changes apply to the next
              deployment.
            </SheetDescription>
          </SheetHeader>

          <div className="mt-6 grid gap-3 sm:gap-2">
            <div className="hidden grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_32px] gap-2 px-0.5 text-xs font-medium text-white/50 sm:grid">
              <span>Key</span>
              <span>Value</span>
            </div>
            {variables.map((variable, index) => (
              // On a phone the value drops under its key, so neither gets cut to a few characters.
              <div
                key={variable.id}
                className="grid grid-cols-[minmax(0,1fr)_32px] items-center gap-x-2 gap-y-1.5 sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_32px] sm:gap-y-2"
              >
                <Input
                  size="sm"
                  aria-label={`Key ${index + 1}`}
                  value={variable.key}
                  onChange={(event) => update(variable.id, { key: event.target.value.toUpperCase().replace(/\s/g, "_") })}
                  spellCheck={false}
                  className="font-mono"
                />
                <Input
                  size="sm"
                  aria-label={`Value ${index + 1}`}
                  value={variable.value}
                  onChange={(event) => update(variable.id, { value: event.target.value })}
                  spellCheck={false}
                  className="font-mono"
                />
                <Button
                  type="button"
                  size="icon"
                  variant="ghost"
                  aria-label={`Remove ${variable.key || "variable"}`}
                  onClick={() => setVariables((current) => current.filter((item) => item.id !== variable.id))}
                  className="col-start-2 row-span-2 row-start-1 size-8 rounded-md hover:text-[#ff4d5e] sm:col-start-3 sm:row-span-1"
                >
                  <Trash2 />
                </Button>
              </div>
            ))}
            <Button
              type="button"
              size="sm"
              variant="ghost"
              className="mt-1 w-fit"
              onClick={() => setVariables((current) => [...current, { id: nextId.current++, key: "", value: "" }])}
            >
              <Plus /> Add variable
            </Button>
          </div>

          <fieldset className="mt-6 border-t border-white/[0.08] pt-5">
            <legend className="float-left mb-3 w-full text-sm font-medium text-white/80">Apply to</legend>
            <div className="clear-left grid gap-2.5">
              {environments.map((environment) => (
                <Checkbox
                  key={environment}
                  size="sm"
                  label={environment}
                  checked={targets.includes(environment)}
                  onCheckedChange={(checked) =>
                    setTargets((current) =>
                      checked ? [...current, environment] : current.filter((item) => item !== environment),
                    )
                  }
                />
              ))}
            </div>
          </fieldset>

          <SheetFooter>
            <SheetClose asChild>
              <Button type="button" variant="ghost">
                Cancel
              </Button>
            </SheetClose>
            <Button type="submit" variant="solid" disabled={targets.length === 0}>
              Save {variables.length} variable{variables.length === 1 ? "" : "s"}
            </Button>
          </SheetFooter>
        </form>
      </SheetContent>
    </Sheet>
  );
}
```

### Bottom sheet

Drag the handle or the header down to dismiss. A flick works too. The backdrop thins as you pull.

```tsx
"use client";

import * as React from "react";
import { CircleArrowUp, Copy, Ellipsis, ExternalLink, RefreshCw, Trash2 } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet";

const actions = [
  { label: "Visit preview", hint: "acme-web-7f3a2c.acme.app", icon: ExternalLink },
  { label: "Copy URL", icon: Copy },
  { label: "Redeploy", hint: "Uses the build cache", icon: RefreshCw },
  { label: "Promote to production", icon: CircleArrowUp },
  { label: "Delete deployment", icon: Trash2, danger: true },
];

export default function SheetBottom() {
  const [last, setLast] = React.useState<string>();

  return (
    <div className="flex flex-col items-center gap-3">
      <Sheet>
        <SheetTrigger asChild>
          <Button variant="ghost" className="border border-white/10">
            <Ellipsis /> Deployment actions
          </Button>
        </SheetTrigger>
        <SheetContent side="bottom" size="lg" className="mx-auto max-w-lg">
          <SheetHeader>
            <SheetTitle className="font-mono text-[15px]">acme-web-7f3a2c</SheetTitle>
            <SheetDescription>Preview · feat/bottom-sheet · 2 minutes ago</SheetDescription>
          </SheetHeader>
          <ul className="-mx-2 mt-4 grid gap-0.5">
            {actions.map((action) => (
              <li key={action.label}>
                <SheetClose asChild>
                  <button
                    type="button"
                    onClick={() => setLast(action.label)}
                    className={
                      "flex min-h-12 w-full cursor-pointer items-center gap-3 rounded-lg px-3 text-left text-sm outline-none transition-colors duration-150 hover:bg-white/[0.04] focus-visible:bg-white/[0.04] focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] " +
                      (action.danger ? "text-[#ff4d5e]" : "text-white/85")
                    }
                  >
                    <action.icon aria-hidden className={action.danger ? "size-4" : "size-4 text-white/50"} />
                    <span className="flex-1">{action.label}</span>
                    {action.hint && <span className="truncate text-xs text-white/35">{action.hint}</span>}
                  </button>
                </SheetClose>
              </li>
            ))}
          </ul>
        </SheetContent>
      </Sheet>
      <p aria-live="polite" className="h-5 text-xs text-white/40">
        {last ? `Picked “${last}”` : "Drag the handle down, or flick it, to dismiss."}
      </p>
    </div>
  );
}
```

### Mobile navigation

A left sheet sized with className. Links close it after navigating.

```tsx
"use client";

import * as React from "react";
import { Activity, Database, LayoutGrid, LifeBuoy, Menu, Rocket, ScrollText, Settings } from "@/components/ui/icons";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";

const links = [
  { label: "Overview", icon: LayoutGrid },
  { label: "Deployments", icon: Rocket, count: 3 },
  { label: "Observability", icon: Activity },
  { label: "Logs", icon: ScrollText },
  { label: "Storage", icon: Database },
  { label: "Settings", icon: Settings },
];

export default function SheetNavigation() {
  const [open, setOpen] = React.useState(false);
  const [page, setPage] = React.useState("Deployments");

  return (
    <div className="flex w-full max-w-sm items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-2 py-2">
      <Sheet open={open} onOpenChange={setOpen}>
        <SheetTrigger asChild>
          <Button size="icon" variant="ghost" aria-label="Open navigation" className="size-9">
            <Menu />
          </Button>
        </SheetTrigger>
        <SheetContent side="left" size="sm" className="w-[min(320px,85vw)]">
          <SheetTitle className="flex items-center gap-2 text-sm">
            <span className="size-2 rounded-full bg-[rgb(var(--neon))] shadow-[0_0_8px_rgb(var(--neon))]" />
            acme-web
          </SheetTitle>
          <nav aria-label="Project" className="-mx-3 mt-6">
            <ul className="grid gap-0.5">
              {links.map((link) => {
                const current = link.label === page;
                return (
                  <li key={link.label}>
                    <a
                      href={`#${link.label.toLowerCase()}`}
                      aria-current={current ? "page" : undefined}
                      onClick={(event) => {
                        event.preventDefault();
                        setPage(link.label);
                        setOpen(false);
                      }}
                      className={
                        "relative flex h-10 items-center gap-3 rounded-lg px-3 text-sm outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] " +
                        (current
                          ? "bg-[rgb(var(--neon)/0.08)] text-white"
                          : "text-white/60 hover:bg-white/[0.04] hover:text-white/90")
                      }
                    >
                      {current && (
                        <span className="absolute inset-y-2 left-0 w-0.5 rounded-full bg-[rgb(var(--neon))] shadow-[0_0_8px_rgb(var(--neon))]" />
                      )}
                      <link.icon aria-hidden className={current ? "size-4 text-[rgb(var(--neon))]" : "size-4"} />
                      <span className="flex-1">{link.label}</span>
                      {link.count && (
                        <span className="rounded-full bg-white/[0.06] px-1.5 text-[11px] tabular-nums text-white/55">
                          {link.count}
                        </span>
                      )}
                    </a>
                  </li>
                );
              })}
            </ul>
          </nav>
          <div className="mt-auto flex items-center gap-3 border-t border-white/[0.08] pt-4">
            <Avatar size="sm" alt="Ana Lima" status="online" />
            <div className="min-w-0 flex-1">
              <p className="truncate text-sm text-white/85">Ana Lima</p>
              <p className="truncate text-xs text-white/40">ana@acme.dev</p>
            </div>
            <Button size="icon" variant="ghost" aria-label="Help" className="size-8">
              <LifeBuoy />
            </Button>
          </div>
        </SheetContent>
      </Sheet>
      <span className="text-sm text-white/50">acme-web /</span>
      <span className="text-sm font-medium text-white/90">{page}</span>
    </div>
  );
}
```

## API reference

### Sheet

Holds the open state. Renders no element of its own.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | — | Controlled open state. |
| `defaultOpen` | `boolean` | `false` | Initial state when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` | — | Called when the trigger, a close button, Escape, the backdrop or a drag asks to change state. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### SheetTrigger

Renders a button that opens the sheet. Also accepts every native button attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Merges trigger behaviour onto its single child element instead of rendering a button. |

### SheetContent

The backdrop and panel in a native <dialog> on the top layer. Other props go to the panel; its children scroll inside a padded body.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `side` | `"top" \| "right" \| "bottom" \| "left"` | `"right"` | The edge it slides in from. The opposite edge is the lit tube. |
| `size` | `"sm" \| "md" \| "lg" \| "full"` | `"md"` | Width for left and right (384, 448, 672px or full), maximum height for top and bottom (40, 65, 85% or full). |
| `showClose` | `boolean` | `true` | Shows the X button in the top-right corner. |

### SheetClose

Renders a button that closes the sheet. Also accepts every native button attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Merges close behaviour onto its single child element instead of rendering a button. |

### SheetHeader, SheetTitle, SheetDescription, SheetFooter

Layout and text parts. Each accepts the native attributes of its element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `SheetHeader` | `div` | — | Title block. On a bottom sheet it's also a drag handle. |
| `SheetTitle` | `h2` | — | Labels the sheet through aria-labelledby. |
| `SheetDescription` | `p` | — | Describes the sheet through aria-describedby, only while it's rendered. |
| `SheetFooter` | `div` | — | Pinned to the bottom of side sheets. Stacks actions on small screens. |

## Accessibility

- Opens with showModal(), so the browser makes the page inert and moves focus to the first focusable element in the sheet.
- Escape and clicks on the backdrop close the sheet after its exit slide. Focus returns to the element that opened it.
- Page scroll is locked while it's open; long content scrolls inside the panel.
- Dragging is a pointer shortcut only: the X button, Escape and SheetClose always work.
- Always include a SheetTitle, even a visually hidden one, so the sheet has an accessible name.
- With reduced motion the sheet fades instead of sliding, the tube doesn't flicker and dragging is off.

## Source

`components/ui/sheet.tsx`

```tsx
"use client";

import * as React from "react";
import {
  AnimatePresence,
  motion,
  useDragControls,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";
import { X } from "@/components/ui/icons";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

/** The single element in `node`. Server Components can pass it as a lazy reference, which Children resolves. */
function resolveElement<P = Record<string, unknown>>(node: React.ReactNode): React.ReactElement<P> | null {
  const [child] = React.Children.toArray(node);
  return React.isValidElement<P>(child) ? child : null;
}

type RenderProp = { asChild?: boolean; render?: React.ReactElement; nativeButton?: boolean; children?: React.ReactNode };

/**
 * Base UI spells asChild as `render={<Link />}` plus `nativeButton`, and the shadcn CLI rewrites
 * asChild into that form in base-* projects. Folds it back into asChild and children.
 */
function fromRenderProp<P extends RenderProp>({ render, nativeButton, ...props }: P): Omit<P, "render" | "nativeButton"> {
  void nativeButton; // Only meaningful to Base UI.
  if (!render) return props;
  const children = props.children === undefined ? render : React.cloneElement(render, undefined, props.children);
  return { ...props, asChild: true, children };
}

const EASE = [0.23, 1, 0.32, 1] as const;

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

interface SheetContextValue {
  open: boolean;
  setOpen: (open: boolean) => void;
  color?: NeonColor;
  contentId: string;
  titleId: string;
  descriptionId: string;
  hasDescription: boolean;
  setHasDescription: (value: boolean) => void;
  triggerRef: { current: HTMLElement | null };
}

const SheetContext = React.createContext<SheetContextValue | null>(null);

function useSheet(component: string) {
  const context = React.useContext(SheetContext);
  if (!context) throw new Error(`<${component}> must be rendered inside <Sheet>.`);
  return context;
}

// Lets SheetHeader start the drag of a bottom sheet, like the handle does.
const SheetDragContext = React.createContext<((event: React.PointerEvent) => void) | null>(null);

type PossibleRef<T> = React.Ref<T> | undefined;

function setRef<T>(ref: PossibleRef<T>, value: T | null) {
  if (typeof ref === "function") ref(value);
  else if (ref) (ref as React.MutableRefObject<T | null>).current = value;
}

function useComposedRefs<T>(a: PossibleRef<T>, b: PossibleRef<T>) {
  return React.useCallback(
    (node: T | null) => {
      setRef(a, node);
      setRef(b, node);
    },
    [a, b],
  );
}

// React 19 reads `ref` from props; React 18 keeps it on the element.
function getElementRef(element: React.ReactElement) {
  if (Number.parseInt(React.version, 10) >= 19) {
    return (element.props as { ref?: React.Ref<HTMLElement> }).ref;
  }
  return (element as unknown as { ref?: React.Ref<HTMLElement> }).ref;
}

/** Merges props onto its single child element. The child's onClick runs first and can preventDefault. */
const Slot = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }>(
  ({ children, className, onClick, ...props }, forwardedRef) => {
    const child = resolveElement<React.HTMLAttributes<HTMLElement>>(children);
    const ref = useComposedRefs(forwardedRef, child ? getElementRef(child) : undefined);
    if (!child) return null;

    return React.cloneElement(child, {
      ...props,
      className: cn(child.props.className, className),
      onClick: (event: React.MouseEvent<HTMLElement>) => {
        child.props.onClick?.(event);
        if (!event.defaultPrevented) onClick?.(event);
      },
      ref,
    } as React.HTMLAttributes<HTMLElement>);
  },
);
Slot.displayName = "SheetSlot";

export interface SheetProps {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Palette name or hex. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  children?: React.ReactNode;
}

function Sheet({ open: openProp, defaultOpen = false, onOpenChange, color, children }: SheetProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
  const [hasDescription, setHasDescription] = React.useState(false);
  const triggerRef = React.useRef<HTMLElement | null>(null);
  const id = React.useId();
  const isControlled = openProp !== undefined;
  const open = isControlled ? openProp : uncontrolledOpen;

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (next === open) return;
      if (!isControlled) setUncontrolledOpen(next);
      onOpenChange?.(next);
    },
    [open, isControlled, onOpenChange],
  );

  const value = React.useMemo<SheetContextValue>(
    () => ({
      open,
      setOpen,
      color,
      contentId: `${id}-content`,
      titleId: `${id}-title`,
      descriptionId: `${id}-description`,
      hasDescription,
      setHasDescription,
      triggerRef,
    }),
    [open, setOpen, color, id, hasDescription],
  );

  return <SheetContext.Provider value={value}>{children}</SheetContext.Provider>;
}
Sheet.displayName = "Sheet";

export interface SheetTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Merge trigger behaviour onto the single child element instead of rendering a button. */
  asChild?: boolean;
  /** Base UI-style asChild: `render={<Link href="/" />}`. */
  render?: React.ReactElement;
  /** Accepted for Base UI compatibility; no effect. */
  nativeButton?: boolean;
}

const SheetTrigger = React.forwardRef<HTMLButtonElement, SheetTriggerProps>(
  (raw, ref) => {
    const { asChild = false, onClick, children, ...props } = fromRenderProp(raw);
    const { open, setOpen, contentId, triggerRef } = useSheet("SheetTrigger");

    const handleClick = (event: React.MouseEvent<HTMLElement>) => {
      onClick?.(event as React.MouseEvent<HTMLButtonElement>);
      if (event.defaultPrevented) return;
      triggerRef.current = event.currentTarget;
      setOpen(true);
    };

    const triggerProps = {
      ...props,
      "aria-haspopup": "dialog" as const,
      "aria-expanded": open,
      "aria-controls": open ? contentId : undefined,
    };

    if (asChild) {
      return (
        <Slot ref={ref} {...triggerProps} onClick={handleClick}>
          {children}
        </Slot>
      );
    }

    return (
      <button ref={ref} type="button" {...triggerProps} onClick={handleClick}>
        {children}
      </button>
    );
  },
);
SheetTrigger.displayName = "SheetTrigger";

const sizes: Record<"x" | "y", Record<"sm" | "md" | "lg" | "full", string>> = {
  x: { sm: "max-w-sm", md: "max-w-md", lg: "max-w-2xl", full: "max-w-none" },
  y: { sm: "max-h-[40dvh]", md: "max-h-[65dvh]", lg: "max-h-[85dvh]", full: "h-dvh max-h-none" },
};

const positions: Record<Side, string> = {
  right: "inset-y-0 right-0 flex w-full justify-end",
  left: "inset-y-0 left-0 flex w-full justify-start",
  top: "inset-x-0 top-0 flex justify-center",
  bottom: "inset-x-0 bottom-0 flex justify-center",
};

const panels: Record<Side, string> = {
  right: "h-full w-full border-l",
  left: "h-full w-full border-r",
  top: "w-full rounded-b-2xl border-b",
  bottom: "w-full rounded-t-2xl border-t",
};

const offscreen: Record<Side, { x?: string; y?: string }> = {
  right: { x: "100%" },
  left: { x: "-100%" },
  top: { y: "-100%" },
  bottom: { y: "100%" },
};

// The edge that faces the page is a tube: a hot core with colored falloff on either side.
const tubes: Record<Side, string> = {
  right: "inset-y-0 -left-[1.5px] w-[3px] bg-[linear-gradient(90deg,rgb(var(--neon)/0.55),rgb(var(--neon))_35%,rgb(255_255_255/0.85)_50%,rgb(var(--neon))_65%,rgb(var(--neon)/0.55))]",
  left: "inset-y-0 -right-[1.5px] w-[3px] bg-[linear-gradient(90deg,rgb(var(--neon)/0.55),rgb(var(--neon))_35%,rgb(255_255_255/0.85)_50%,rgb(var(--neon))_65%,rgb(var(--neon)/0.55))]",
  top: "inset-x-8 -bottom-[1.5px] h-[3px] rounded-full bg-[linear-gradient(180deg,rgb(var(--neon)/0.55),rgb(var(--neon))_35%,rgb(255_255_255/0.85)_50%,rgb(var(--neon))_65%,rgb(var(--neon)/0.55))]",
  bottom: "inset-x-8 -top-[1.5px] h-[3px] rounded-full bg-[linear-gradient(180deg,rgb(var(--neon)/0.55),rgb(var(--neon))_35%,rgb(255_255_255/0.85)_50%,rgb(var(--neon))_65%,rgb(var(--neon)/0.55))]",
};

// The light the tube throws onto the panel's own surface.
const spills: Record<Side, string> = {
  right: "inset-y-0 left-0 w-20 bg-[linear-gradient(90deg,rgb(var(--neon)/0.09),transparent)]",
  left: "inset-y-0 right-0 w-20 bg-[linear-gradient(270deg,rgb(var(--neon)/0.09),transparent)]",
  top: "inset-x-0 bottom-0 h-16 bg-[linear-gradient(0deg,rgb(var(--neon)/0.09),transparent)]",
  bottom: "inset-x-0 top-0 h-16 bg-[linear-gradient(180deg,rgb(var(--neon)/0.09),transparent)]",
};

const ignite = `@keyframes neonkit-sheet-ignite{0%{opacity:0}12%{opacity:.8}22%{opacity:.2}40%{opacity:1}52%{opacity:.5}68%,100%{opacity:1}}`;

export interface SheetContentProps
  extends Omit<
    React.HTMLAttributes<HTMLDivElement>,
    "color" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"
  > {
  /** The edge the panel slides in from. */
  side?: Side;
  /** Width for left and right sheets, maximum height for top and bottom ones. */
  size?: "sm" | "md" | "lg" | "full";
  /** Renders the X button in the top-right corner. */
  showClose?: boolean;
}

const SheetContent = React.forwardRef<HTMLDivElement, SheetContentProps>(
  ({ side = "right", size = "md", showClose = true, className, style, children, ...props }, ref) => {
    const { open, setOpen, color, contentId, titleId, descriptionId, hasDescription, triggerRef } =
      useSheet("SheetContent");
    const reduceMotion = useReducedMotion();
    const dialogRef = React.useRef<HTMLDialogElement>(null);
    const panelRef = React.useRef<HTMLDivElement>(null);
    const composedRef = useComposedRefs(ref, panelRef);
    const saved = React.useRef<{ overflow: string; focus: HTMLElement | null } | null>(null);
    const dragControls = useDragControls();
    const dragY = useMotionValue(0);
    // The backdrop thins out as the sheet is pulled down.
    const backdropOpacity = useTransform(dragY, [0, 360], [1, 0.25]);
    const [dragging, setDragging] = React.useState(false);
    const draggable = side === "bottom";

    React.useLayoutEffect(() => {
      const dialog = dialogRef.current;
      if (!open || !dialog || dialog.open) return;

      // Safari doesn't focus buttons on click, so fall back to the trigger that was pressed.
      const active = document.activeElement;
      saved.current = {
        overflow: document.documentElement.style.overflow,
        focus: active instanceof HTMLElement && active !== document.body ? active : triggerRef.current,
      };
      triggerRef.current = null;
      dragY.set(0);
      document.documentElement.style.overflow = "hidden";
      dialog.showModal();
      // showModal focuses the first control while the panel is still offscreen, and the browser can
      // scroll the dialog to bring it into view. Undo that so the panel lands where it should.
      dialog.scrollTop = 0;
      dialog.scrollLeft = 0;
    }, [open, triggerRef, dragY]);

    React.useEffect(
      () => () => {
        if (saved.current) document.documentElement.style.overflow = saved.current.overflow;
      },
      [],
    );

    // The <dialog> stays open while the panel slides out, then closes for real here.
    const finish = () => {
      const dialog = dialogRef.current;
      // Only take focus back if it is still ours: inside the sheet or lost to the page.
      const active = document.activeElement;
      const ours = !active || active === document.body || (dialog?.contains(active) ?? false);
      if (dialog?.open) dialog.close();
      const restore = saved.current;
      saved.current = null;
      if (!restore) return;
      document.documentElement.style.overflow = restore.overflow;
      const target = restore.focus;
      // Skip a trigger that has since been removed or hidden, e.g. a menu button only shown on mobile.
      const visible = typeof target?.checkVisibility !== "function" || target.checkVisibility();
      if (ours && target?.isConnected && visible) target.focus({ preventScroll: true });
    };

    const startDrag = React.useCallback(
      (event: React.PointerEvent) => {
        const target = event.target as HTMLElement;
        if (event.button !== 0 || target.closest("button, a, input, textarea, select, [contenteditable]")) return;
        dragControls.start(event);
      },
      [dragControls],
    );

    const axis = side === "left" || side === "right" ? "x" : "y";
    const hidden = reduceMotion ? { opacity: 0 } : { opacity: 1, ...offscreen[side] };
    const shown = reduceMotion ? { opacity: 1 } : { opacity: 1, x: 0, y: 0 };
    const enter = { duration: reduceMotion ? 0.15 : 0.28, ease: EASE };
    const exit = { duration: reduceMotion ? 0.12 : 0.2, ease: EASE };

    return (
      <dialog
        ref={dialogRef}
        id={contentId}
        aria-labelledby={titleId}
        aria-describedby={hasDescription ? descriptionId : undefined}
        onCancel={(event) => {
          // React passes a nested dialog's cancel up to every ancestor's handler; only Escape on this one counts.
          if (event.target !== event.currentTarget) return;
          // Escape: let the exit animation run instead of closing instantly.
          event.preventDefault();
          setOpen(false);
        }}
        onClose={(event) => {
          if (event.target !== event.currentTarget) return;
          if (!dialogRef.current?.open) setOpen(false);
        }}
        style={neonVars(color)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden border-0 bg-transparent p-0 text-white/90 outline-none backdrop:bg-transparent",
        )}
      >
        <AnimatePresence onExitComplete={finish}>
          {open && (
            <motion.div
              key="overlay"
              aria-hidden
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0, transition: exit }}
              transition={enter}
              onClick={() => setOpen(false)}
              className="absolute inset-0"
            >
              <motion.div
                style={{ opacity: draggable ? backdropOpacity : 1 }}
                className="absolute inset-0 bg-black/55 backdrop-blur-[3px]"
              />
            </motion.div>
          )}
          {open && (
            <motion.div
              key="panel"
              initial={hidden}
              animate={shown}
              exit={{ ...hidden, transition: exit }}
              transition={enter}
              className={cn("pointer-events-none absolute", positions[side])}
            >
              <motion.div
                ref={composedRef}
                drag={draggable && !reduceMotion ? "y" : false}
                dragControls={dragControls}
                dragListener={false}
                dragConstraints={{ top: 0, bottom: 0 }}
                dragElastic={{ top: 0.04, bottom: 1 }}
                dragTransition={{ bounceStiffness: 600, bounceDamping: 45 }}
                onDragStart={() => setDragging(true)}
                onDragEnd={(_, info) => {
                  setDragging(false);
                  const height = panelRef.current?.offsetHeight ?? 0;
                  if (info.offset.y > Math.min(height * 0.3, 180) || info.velocity.y > 650) {
                    // Hold where the finger let go and let the exit slide carry it off, instead of snapping back first.
                    dragY.stop();
                    setOpen(false);
                  }
                }}
                style={{ ...style, y: draggable ? dragY : undefined }}
                className={cn(
                  "pointer-events-auto relative flex flex-col border-white/10 bg-[#0a0b11] text-white/90",
                  "shadow-[0_0_0_1px_rgb(0_0_0/0.5),0_24px_80px_-12px_rgb(0_0_0/0.9)]",
                  panels[side],
                  sizes[axis][size],
                  className,
                )}
                {...props}
              >
                <span aria-hidden className={cn("pointer-events-none absolute", spills[side])} />
                <span
                  aria-hidden
                  className={cn(
                    "pointer-events-none absolute z-10",
                    "shadow-[0_0_6px_rgb(var(--neon)/CALC(0.9*var(--neon-glow,1))),0_0_18px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1))),0_0_44px_rgb(var(--neon)/CALC(0.25*var(--neon-glow,1)))]",
                    "[animation:neonkit-sheet-ignite_380ms_linear_160ms_both] motion-reduce:[animation:none]",
                    tubes[side],
                  )}
                />
                <style>{ignite}</style>

                {draggable && (
                  <div
                    aria-hidden
                    onPointerDown={startDrag}
                    className="flex h-7 shrink-0 cursor-grab touch-none items-end justify-center active:cursor-grabbing"
                  >
                    <span
                      className={cn(
                        "h-1 w-10 rounded-full transition-[background-color,box-shadow] duration-150",
                        dragging
                          ? "bg-[rgb(var(--neon)/0.85)] shadow-[0_0_10px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1)))]"
                          : "bg-white/20",
                      )}
                    />
                  </div>
                )}

                <SheetDragContext.Provider value={draggable && !reduceMotion ? startDrag : null}>
                  <div
                    className={cn(
                      "relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain",
                      "[scrollbar-color:rgb(255_255_255/0.15)_transparent] [scrollbar-width:thin]",
                      draggable ? "px-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-3" : "p-6",
                    )}
                  >
                    {children}
                  </div>
                </SheetDragContext.Provider>

                {showClose && (
                  <button
                    type="button"
                    aria-label="Close"
                    onClick={() => setOpen(false)}
                    className={cn(
                      "absolute right-3.5 flex size-8 items-center justify-center rounded-lg text-white/50 outline-none",
                      "transition-[background-color,color,transform] duration-150 ease-out hover:bg-white/[0.06] hover:text-white/90 active:scale-[0.97]",
                      "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b11]",
                      draggable ? "top-[30px]" : "top-3.5",
                    )}
                  >
                    <X className="size-4" aria-hidden />
                  </button>
                )}
              </motion.div>
            </motion.div>
          )}
        </AnimatePresence>
      </dialog>
    );
  },
);
SheetContent.displayName = "SheetContent";

const SheetHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, onPointerDown, ...props }, ref) => {
    const startDrag = React.useContext(SheetDragContext);
    return (
      <div
        ref={ref}
        onPointerDown={(event) => {
          onPointerDown?.(event);
          if (!event.defaultPrevented) startDrag?.(event);
        }}
        className={cn("flex flex-col gap-1.5 pr-8", startDrag && "touch-none", className)}
        {...props}
      />
    );
  },
);
SheetHeader.displayName = "SheetHeader";

const SheetFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      className={cn("mt-auto flex flex-col-reverse gap-2 pt-6 sm:flex-row sm:justify-end", className)}
      {...props}
    />
  ),
);
SheetFooter.displayName = "SheetFooter";

const SheetTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
  ({ className, ...props }, ref) => {
    const { titleId } = useSheet("SheetTitle");
    return (
      <h2
        ref={ref}
        className={cn("text-base font-semibold leading-6 tracking-[-0.01em] text-white/90", className)}
        {...props}
        id={titleId}
      />
    );
  },
);
SheetTitle.displayName = "SheetTitle";

const SheetDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
  ({ className, ...props }, ref) => {
    const { descriptionId, setHasDescription } = useSheet("SheetDescription");

    React.useLayoutEffect(() => {
      setHasDescription(true);
      return () => setHasDescription(false);
    }, [setHasDescription]);

    return (
      <p
        ref={ref}
        className={cn("text-sm leading-6 text-white/55", className)}
        {...props}
        id={descriptionId}
      />
    );
  },
);
SheetDescription.displayName = "SheetDescription";

export interface SheetCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Merge close behaviour onto the single child element instead of rendering a button. */
  asChild?: boolean;
  /** Base UI-style asChild: `render={<Link href="/" />}`. */
  render?: React.ReactElement;
  /** Accepted for Base UI compatibility; no effect. */
  nativeButton?: boolean;
}

const SheetClose = React.forwardRef<HTMLButtonElement, SheetCloseProps>(
  (raw, ref) => {
    const { asChild = false, onClick, children, ...props } = fromRenderProp(raw);
    const { setOpen } = useSheet("SheetClose");

    const handleClick = (event: React.MouseEvent<HTMLElement>) => {
      onClick?.(event as React.MouseEvent<HTMLButtonElement>);
      if (!event.defaultPrevented) setOpen(false);
    };

    if (asChild) {
      return (
        <Slot ref={ref} {...props} onClick={handleClick}>
          {children}
        </Slot>
      );
    }

    return (
      <button ref={ref} type="button" {...props} onClick={handleClick}>
        {children}
      </button>
    );
  },
);
SheetClose.displayName = "SheetClose";

export {
  Sheet,
  SheetTrigger,
  SheetContent,
  SheetHeader,
  SheetFooter,
  SheetTitle,
  SheetDescription,
  SheetClose,
};
```
