shadimage

Sheet

Slide-out side drawer panel with spring animation and ESC dismissal.

components/ui/sheet.tsx

'use client'

import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'

export const SheetRoot = SheetPrimitive.Root
export const SheetTrigger = SheetPrimitive.Trigger
export const SheetClose = SheetPrimitive.Close

const sideClasses = {
  top: 'inset-x-0 top-0 border-b-3 border-r-0 border-l-0 border-t-0 rounded-b-[28px]',
  bottom: 'inset-x-0 bottom-0 border-t-3 border-r-0 border-l-0 border-b-0 rounded-t-[28px]',
  left: 'inset-y-0 left-0 w-full max-w-sm border-r-3 border-t-0 border-b-0 border-l-0 rounded-r-[28px]',
  right: 'inset-y-0 right-0 w-full max-w-sm border-l-3 border-t-0 border-b-0 border-r-0 rounded-l-[28px]',
} as const

const translateClasses = {
  top: 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-top-full data-[state=open]:slide-in-from-top-full',
  bottom: 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-full data-[state=open]:slide-in-from-bottom-full',
  left: 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-left-full data-[state=open]:slide-in-from-left-full',
  right: 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-right-full',
} as const

export interface SheetContentProps
  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> {
  side?: 'top' | 'bottom' | 'left' | 'right'
  closeLabel?: string
}

export const SheetOverlay = React.forwardRef<
  React.ElementRef<typeof SheetPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <SheetPrimitive.Overlay
    ref={ref}
    className={cn(
      'fixed inset-0 z-[70] bg-rk-ink/40 backdrop-blur-xs data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
      className
    )}
    {...props}
  />
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName

export const SheetContent = React.forwardRef<
  React.ElementRef<typeof SheetPrimitive.Content>,
  SheetContentProps
>(({ className, side = 'right', closeLabel = 'Close panel', children, ...props }, ref) => (
  <SheetPrimitive.Portal>
    <SheetOverlay />
    <SheetPrimitive.Content
      ref={ref}
      className={cn(
        'fixed z-[80] flex flex-col bg-rk-surface font-sans rk-border rk-shadow-xl transition-all duration-200',
        sideClasses[side],
        translateClasses[side],
        className
      )}
      {...props}
    >
      <div className="flex items-start justify-between gap-4 p-5 border-b-3 border-rk-ink/20">
        {children}
      </div>
      <SheetPrimitive.Close
        type="button"
        aria-label={closeLabel}
        className="absolute top-5 right-5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white rk-border-sm rk-shadow-sm transition-all hover:bg-rk-primary active:translate-x-[2px] active:translate-y-[2px] active:shadow-none focus:outline-none focus:ring-4 focus:ring-black cursor-pointer"
      >
        <X className="h-4 w-4 stroke-[3]" aria-hidden="true" />
      </SheetPrimitive.Close>
    </SheetPrimitive.Content>
  </SheetPrimitive.Portal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName

export const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
  <div className={cn('flex flex-col space-y-1 text-left', className)} {...props} />
)
SheetHeader.displayName = 'SheetHeader'

export const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
  <div className={cn('mt-auto flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
)
SheetFooter.displayName = 'SheetFooter'

export const SheetTitle = React.forwardRef<
  React.ElementRef<typeof SheetPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
  <SheetPrimitive.Title
    ref={ref}
    className={cn('font-display text-xl font-black pr-8', className)}
    {...props}
  />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName

export const SheetDescription = React.forwardRef<
  React.ElementRef<typeof SheetPrimitive.Description>,
  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
  <SheetPrimitive.Description
    ref={ref}
    className={cn('mt-1 text-xs font-bold opacity-60', className)}
    {...props}
  />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName

export interface SheetProps {
  open: boolean
  onClose: () => void
  side?: 'left' | 'right'
  title?: React.ReactNode
  description?: React.ReactNode
  children?: React.ReactNode
  className?: string
  closeLabel?: string
}

export function Sheet({
  open,
  onClose,
  side = 'right',
  title,
  description,
  children,
  className,
  closeLabel = 'Close panel',
}: SheetProps) {
  return (
    <SheetRoot open={open} onOpenChange={(next) => !next && onClose()}>
      <SheetContent side={side} closeLabel={closeLabel} className={className}>
        <SheetHeader>
          {title && <SheetTitle>{title}</SheetTitle>}
          {description && <SheetDescription>{description}</SheetDescription>}
        </SheetHeader>
        <div className="flex-1 overflow-y-auto p-5">{children}</div>
      </SheetContent>
    </SheetRoot>
  )
}

No selection tags yet — empty means no evidence, not lack of support.

DependenciesBundle weight · medium

icons
lucide-react
animation
motion