my/ui

Command Palette

Search for a command to run...

All components

Side Panel

modals

Sliding side panel component with customizable positioning and animations

responsive · 560px

Install

Same command in any shadcn project — React (Vite/CRA), Next.js, Remix, Astro, and more:

$npx shadcn@latest add https://your-domain/r/side-panel.json

Usage

"use client"

import { useState } from "react"
import { SidePanel } from "@/registry/cult-ui/side-panel"

function MenuIcon({ open }: { open: boolean }) {
  return (
    <div className="flex flex-col gap-1 w-5 h-5 justify-center">
      <span
        className={`block h-0.5 bg-white transition-all duration-300 ${open ? "rotate-45 translate-y-1.5" : ""}`}
      />
      <span
        className={`block h-0.5 bg-white transition-all duration-300 ${open ? "opacity-0" : ""}`}
      />
      <span
        className={`block h-0.5 bg-white transition-all duration-300 ${open ? "-rotate-45 -translate-y-1.5" : ""}`}
      />
    </div>
  )
}

const navItems = [
  { label: "Dashboard", description: "Overview & metrics" },
  { label: "Projects", description: "Active work" },
  { label: "Analytics", description: "Charts & reports" },
  { label: "Settings", description: "Account & preferences" },
]

export default function Demo() {
  const [open, setOpen] = useState(false)

  return (
    <div className="flex items-start justify-start w-full min-h-[480px] bg-background">
      <SidePanel
        panelOpen={open}
        handlePanelOpen={() => setOpen((v) => !v)}
        renderButton={(toggle) => (
          <button
            onClick={toggle}
            className="flex items-center gap-2 text-white text-sm font-medium focus:outline-none"
            aria-label={open ? "Close menu" : "Open menu"}
          >
            <MenuIcon open={open} />
            {open && <span className="whitespace-nowrap">Close</span>}
          </button>
        )}
      >
        <nav className="px-4 pb-6 flex flex-col gap-1">
          {navItems.map((item) => (
            <div
              key={item.label}
              className="rounded-lg px-3 py-2 cursor-pointer hover:bg-neutral-700 transition-colors"
            >
              <p className="text-white text-sm font-medium">{item.label}</p>
              <p className="text-neutral-400 text-xs">{item.description}</p>
            </div>
          ))}
        </nav>
      </SidePanel>
    </div>
  )
}

Component source

"use client"

import React, { forwardRef, ReactNode } from "react"
import { AnimatePresence, motion, MotionConfig } from "motion/react"
import useMeasure from "react-use-measure"

import { cn } from "@/lib/utils"

type PanelContainerProps = {
  panelOpen: boolean
  handlePanelOpen: () => void
  className?: string
  videoUrl?: string
  renderButton?: (handleToggle: () => void) => ReactNode
  children: ReactNode
}

const sectionVariants = {
  open: {
    width: "97%",
    transition: {
      duration: 0.3,
      ease: [0.42, 0, 0.58, 1] as const,
      delayChildren: 0.3,
      staggerChildren: 0.2,
    },
  },
  closed: {
    transition: { duration: 0.2, ease: [0.42, 0, 0.58, 1] as const },
  },
}

const sharedTransition = { duration: 0.6, ease: [0.42, 0, 0.58, 1] as const }

export const SidePanel = forwardRef<HTMLDivElement, PanelContainerProps>(
  ({ panelOpen, handlePanelOpen, className, renderButton, children }, ref) => {
    const [measureRef, bounds] = useMeasure()

    return (
      <ResizablePanel>
        <motion.div
          className={cn(
            "bg-neutral-900 rounded-r-[44px] w-[160px] md:w-[260px]",
            className
          )}
          animate={panelOpen ? "open" : "closed"}
          variants={sectionVariants}
          transition={{ duration: 0.2, ease: [0.42, 0, 0.58, 1] as const }}
        >
          <motion.div
            animate={{ height: bounds.height > 0 ? bounds.height : 0.1 }}
            className="h-auto"
            transition={{ type: "spring", bounce: 0.02, duration: 0.65 }}
          >
            <div ref={measureRef}>
              <AnimatePresence mode="popLayout">
                <motion.div
                  exit={{ opacity: 0 }}
                  transition={{
                    ...sharedTransition,
                    duration: sharedTransition.duration / 2,
                  }}
                  key="form"
                >
                  <div
                    className={cn(
                      "flex items-center w-full justify-start pl-4 md:pl-4 py-1 md:py-3",
                      panelOpen ? "pr-3" : ""
                    )}
                  >
                    {renderButton && renderButton(handlePanelOpen)}
                  </div>

                  {panelOpen && (
                    <motion.div
                      exit={{ opacity: 0 }}
                      transition={sharedTransition}
                    >
                      {children}
                    </motion.div>
                  )}
                </motion.div>
              </AnimatePresence>
            </div>
          </motion.div>
        </motion.div>
      </ResizablePanel>
    )
  }
)

SidePanel.displayName = "SidePanel"

export default SidePanel

type ResizablePanelProps = {
  children: React.ReactNode
}

const ResizablePanel = React.forwardRef<HTMLDivElement, ResizablePanelProps>(
  ({ children }, ref) => {
    const transition = {
      type: "tween" as const,
      ease: [0.42, 0, 0.58, 1] as const,
      duration: 0.4,
    }

    return (
      <MotionConfig transition={transition}>
        <div className="flex w-full flex-col items-start">
          <div className="mx-auto w-full">
            <div
              ref={ref}
              className={cn(
                children ? "rounded-r-none" : "rounded-sm",
                "relative overflow-hidden"
              )}
            >
              {children}
            </div>
          </div>
        </div>
      </MotionConfig>
    )
  }
)

ResizablePanel.displayName = "ResizablePanel"

Source: Cult UI