VizStats UI

Detail Drawer

Detail Drawer Component

Installation


Install the following dependencies:

Install the necessary dependencies using your preferred package manager:

npm i zustand

Setup the detailDrawerStore

Create a new file called useDetailDrawerStore.ts in your store directory: store/useDetailDrawerStore.ts

import { create } from "zustand";
 
type DetailState = {
  isDetailDrawerOpen: boolean; // Tracks drawer open/close state
  detailDrawerContent: React.ReactNode | null; // Content for the drawer
};
 
type DetailActions = {
  toggleDetailDrawer: () => void;
  setDetailDrawerContent: (content: React.ReactNode) => void; // Function to set dynamic content
  openDetailDrawer: () => void;
  closeDetailDrawer: (clearContent?: boolean) => void; // Option to clear content
  resetDetailDrawer: () => void; // Resets the drawer state completely
};
 
const useDetailDrawerStore = create<DetailState & DetailActions>((set) => ({
  isDetailDrawerOpen: false,
  detailDrawerContent: null,
 
  toggleDetailDrawer: () =>
    set((state) => ({ isDetailDrawerOpen: !state.isDetailDrawerOpen })),
 
  setDetailDrawerContent: (content) => set({ detailDrawerContent: content }),
 
  openDetailDrawer: () => set({ isDetailDrawerOpen: true }),
 
  closeDetailDrawer: (clearContent = false) =>
    set({
      isDetailDrawerOpen: false,
      ...(clearContent && { detailDrawerContent: null }),
    }),
 
  resetDetailDrawer: () =>
    set({ isDetailDrawerOpen: false, detailDrawerContent: null }),
}));
 
export default useDetailDrawerStore;

Adding shadcn/ui components

Once shadcn/ui is installed, you can add its components to your project using the following command:

npx shadcn@latest add button scroll-area

Create Detail Drawer Component

Create a new component called detail-drawer.tsx

"use client";
 
import { useEffect, useRef, useState } from "react";
import {
  motion,
  useMotionValue,
  useTransform,
  AnimatePresence,
} from "framer-motion";
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
 
import useMounted from "@/hooks/useMounted";
import useDeviceType from "@/hooks/useDeviceType";
import useDetailDrawerStore from "@/store/useDetailDrawerStore";
 
export function LargeScreenDrawer() {
  const {
    isDetailDrawerOpen,
    setDetailDrawerContent,
    closeDetailDrawer,
    detailDrawerContent,
  } = useDetailDrawerStore();
  const drawerRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    if (isDetailDrawerOpen && drawerRef.current) {
      drawerRef.current.focus();
    }
  }, [isDetailDrawerOpen]);
 
  return (
    <div
      ref={drawerRef}
      className={`translate-x-full transition-transform duration-500 ease-in-out fixed bottom-0 z-[32] h-2/4 w-screen rounded-t-2xl bg-neutral-50 shadow-lg dark:bg-neutral-900 sm:right-0 sm:top-0 sm:h-screen sm:w-[25rem] sm:rounded-none 2xl:w-[30rem] ${
        isDetailDrawerOpen ? "translate-x-0" : ""
      }`}
      role="dialog"
      aria-modal="true"
      tabIndex={-1}
    >
      {isDetailDrawerOpen && (
        <div className="flex h-full w-full flex-col gap-4">
          <Button
            variant="secondary"
            className="transition-all duration-300 active:scale-90 group absolute left-0 top-1/2 z-[60] -m-[25px] h-16 w-6 -translate-y-1/2 transform rounded-none rounded-l-md bg-background dark:bg-neutral-900/50 dark:hover:bg-neutral-900 p-0 shadow-md"
            onClick={() => {
              closeDetailDrawer();
              setDetailDrawerContent(null); // Reset selected site (Map Marker active state)
            }}
          >
            <ChevronRight className="h-6 w-6 text-foreground/60 group-hover:animate-[pulse_2s_infinite]" />
            <span className="sr-only">Close</span>
          </Button>
          <ScrollArea className="h-full">{detailDrawerContent}</ScrollArea>
        </div>
      )}
    </div>
  );
}
 
export function MobileBottomDrawer() {
  const { closeDetailDrawer, isDetailDrawerOpen, detailDrawerContent } =
    useDetailDrawerStore();
  const [isExpanded, setIsExpanded] = useState(false);
  const y = useMotionValue(0);
  const height = useTransform(
    y,
    [0, -window.innerHeight * 0.4],
    ["30vh", "50vh"]
  );
 
  useEffect(() => {
    if (isDetailDrawerOpen) {
      y.set(0);
    }
  }, [isDetailDrawerOpen, y]);
 
  const handleDragEnd = (
    _: any,
    info: { offset: { y: number }; velocity: { y: number } }
  ) => {
    const offsetY = info.offset.y;
    const velocityY = info.velocity.y;
 
    if (offsetY < -window.innerHeight * 0.2 || velocityY < -100) {
      y.set(-window.innerHeight * 0.4);
      setIsExpanded(true);
    } else if (offsetY > window.innerHeight * 0.2 || velocityY > 100) {
      if (isExpanded) {
        y.set(0);
        setIsExpanded(false);
      } else {
        closeDetailDrawer();
        //   setSelectedSite(null);
      }
    } else {
      y.set(isExpanded ? -window.innerHeight * 0.4 : 0);
    }
  };
 
  return (
    <AnimatePresence>
      {isDetailDrawerOpen && (
        <motion.div
          className="fixed bottom-0 left-0 right-0 z-[32]"
          style={{ height }}
          drag="y"
          dragConstraints={{ top: -window.innerHeight * 0.4, bottom: 0 }}
          onDragEnd={handleDragEnd}
          initial={{ y: window.innerHeight }}
          animate={{ y: isExpanded ? -window.innerHeight * 0.4 : 0 }}
          exit={{ y: window.innerHeight }}
          transition={{ type: "spring", stiffness: 300, damping: 30 }}
        >
          <div className="flex h-screen w-full flex-col rounded-t-lg bg-neutral-50 shadow-lg dark:bg-neutral-900">
            <div className="flex justify-center p-3">
              <div className="h-1 w-24 rounded-full bg-gray-300/70">
                <span className="sr-only">
                  Drag up to expand details, drag down to close.
                </span>
              </div>
            </div>
            <ScrollArea className="h-full pb-32">
              {detailDrawerContent}
            </ScrollArea>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}
 
export function DetailDrawer() {
  const { isMobile } = useDeviceType();
  const mounted = useMounted();
 
  if (!mounted) return null;
  return isMobile ? <MobileBottomDrawer /> : <LargeScreenDrawer />;
}
 
// Global trigger for opening the drawer
type DetailDrawerTriggerProps = {
  content: React.ReactNode;
  children: React.ReactNode;
};
 
export function DetailDrawerTrigger({
  content,
  children,
}: DetailDrawerTriggerProps) {
  const { setDetailDrawerContent, toggleDetailDrawer } = useDetailDrawerStore();
 
  const handleClick = () => {
    setDetailDrawerContent(content);
    toggleDetailDrawer();
  };
 
  return (
    <Button
      variant="outline"
      className="transition-all duration-300 active:scale-90"
      onClick={handleClick}
    >
      {children}
    </Button>
  );
}

Usage


Create a new file called page.tsx in your pages directory: pages/page.tsx

import { DetailDrawerTrigger } from "@/components/elements/detail-drawer";
 
export default function Page() {
  return (
    <div className="w-full min-h-72 flex h-full items-center justify-center">
      <DetailDrawerTrigger
        className="rounded px-4 py-2"
        content={<div className="p-6">Detail Drawer Contents</div>}
      >
        Detail Drawer
      </DetailDrawerTrigger>
    </div>
  );
}

Add the DetailDrawer component to your root layout:

import { DetailDrawer } from "@/components/elements/detail-drawer";
 
export default function RootLayout({ children }: RootLayoutProps) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head />
      <body>
        {children}
        <DetailDrawer />
      </body>
    </html>
  );
}

Usage with Button

Create a new file called page.tsx in your pages directory: pages/page.tsx

import useDetailDrawerStore from "@/store/useDetailDrawerStore";
 
export default function Page() {
  const { setDetailDrawerContent, openDetailDrawer } = useDetailDrawerStore();
 
  const handleClick = () => {
    setDetailDrawerContent(<div className="p-6">Detail Drawer Contents</div>);
    openDetailDrawer();
  };
 
  return (
    <div className="w-full min-h-72 flex h-full items-center justify-center">
      <Button
        variant="outline"
        className="transition-all duration-300 active:scale-90"
        onClick={handleClick}
      >
        Detail Drawer
      </Button>
    </div>
  );
}

Composition


This component is a combination of two main components: DetailDrawer and DetailDrawerTrigger.

Sub Components

  • DetailDrawer - Main component for the drawer
  • DetailDrawerTrigger - Trigger for opening the drawer

Props

Detail Drawer Props

NameTypeDefaultDescription
classNamestring""Additional classes for the detail drawer
childrenReact.ReactNodenullContent of the detail drawer

Detail Drawer Trigger Props

NameTypeDefaultDescription
childrenReact.ReactNodenullContent of the detail drawer trigger
classNamestring""Additional classes for the detail drawer trigger
contentReact.ReactNodenullContent of the detail drawer

On this page