Skip to content

Family drawer

My attempt at recreating the family drawer from the animations.dev course.

Uses
motion

Code

import {
  createContext,
  use,
  useCallback,
  useEffect,
  useMemo,
  useState,
  type ComponentProps,
  type CSSProperties,
  type ReactNode,
  type RefCallback,
} from "react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import { cn } from "@manikrana/ui/lib/utils";
import { CloseSmallIcon } from "@/components/icons";

const MIN_FADE = 0.15;
const MAX_FADE = 0.27;

const SURFACE_SPRING = { type: "spring", bounce: 0.03, visualDuration: 0.15 } as const;
const CONTENT = {
  hidden: { opacity: 0, y: 8, filter: "blur(3px)" },
  shown: { opacity: 1, y: 0, filter: "blur(0px)" },
  enter: { duration: 0.1, delay: 0.015 },
  exit: { duration: 0.07 },
};

const PRESS =
  "transform-gpu outline-none [backface-visibility:hidden] transition-[scale,background-color] duration-150 ease-out focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.96]";
const PRESSABLE = cn("flex h-11 w-full items-center text-base leading-none font-semibold", PRESS);

const OUTSIDE = "Family drawer parts must be rendered inside <FamilyDrawer>.";

type DrawerContextValue = {
  open: boolean;
  setOpen: (open: boolean) => void;
  page: string;
  setPage: (page: string) => void;
};

// Separate from DrawerContext so `useFamilyDrawer` consumers don't re-render on it.
type FrameContextValue = {
  measure: RefCallback<HTMLDivElement>;
  fadeDuration: number;
  reduceMotion: boolean | null;
};

const DrawerContext = createContext<DrawerContextValue | null>(null);
const FrameContext = createContext<FrameContextValue | null>(null);
const MeasuringContext = createContext(false);
// Per layer, so a fading-out layer keeps its old page instead of reading the drawer's.
const ActivePageContext = createContext<string | null>(null);

type ElementProps<Tag extends "div" | "button"> = Omit<
  ComponentProps<Tag>,
  "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"
>;

function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (value: T) => void) {
  const [inner, setInner] = useState(defaultValue);
  const controlled = value !== undefined;
  const set = useCallback(
    (next: T) => {
      if (!controlled) setInner(next);
      onChange?.(next);
    },
    [controlled, onChange],
  );

  return [controlled ? value : inner, set] as const;
}

function useMeasuredHeight() {
  const [element, setElement] = useState<HTMLElement | null>(null);
  const [height, setHeight] = useState(0);

  useEffect(() => {
    if (!element) return;

    const updateHeight = () => setHeight(element.scrollHeight);
    const observer = new ResizeObserver(updateHeight);

    updateHeight();
    observer.observe(element);

    return () => observer.disconnect();
  }, [element]);

  return [setElement, height] as const;
}

function useFrame() {
  const frame = use(FrameContext);

  if (!frame) throw new Error(OUTSIDE);
  return frame;
}

export function useFamilyDrawer() {
  const drawer = use(DrawerContext);

  if (!drawer) throw new Error(OUTSIDE);
  return drawer;
}

export type FamilyDrawerProps = ElementProps<"div"> & {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  page?: string;
  defaultPage?: string;
  onPageChange?: (page: string) => void;
  closedWidth?: number;
  closedHeight?: number;
  openWidth?: number;
};

export function FamilyDrawer({
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  page: pageProp,
  defaultPage = "",
  onPageChange,
  closedWidth = 190,
  closedHeight = 44,
  openWidth = 338,
  className,
  style,
  children,
  ...props
}: FamilyDrawerProps) {
  const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);
  const [page, setPage] = useControllable(pageProp, defaultPage, onPageChange);
  const [measure, contentHeight] = useMeasuredHeight();
  const reduceMotion = useReducedMotion();
  const [lastHeight, setLastHeight] = useState(contentHeight);
  const [fadeDuration, setFadeDuration] = useState(MIN_FADE);

  if (contentHeight !== lastHeight) {
    setLastHeight(contentHeight);
    setFadeDuration(
      lastHeight
        ? Math.min(Math.max(Math.abs(contentHeight - lastHeight) / 500, MIN_FADE), MAX_FADE)
        : MIN_FADE,
    );
  }

  const drawer = useMemo(() => ({ open, setOpen, page, setPage }), [open, setOpen, page, setPage]);
  const frame = useMemo(
    () => ({ measure, fadeDuration, reduceMotion }),
    [measure, fadeDuration, reduceMotion],
  );

  return (
    <MotionConfig reducedMotion="user">
      <DrawerContext value={drawer}>
        <FrameContext value={frame}>
          <motion.div
            animate={
              open && contentHeight
                ? { width: openWidth, height: contentHeight }
                : { width: closedWidth, height: closedHeight }
            }
            className={cn(
              "relative max-w-full overflow-hidden rounded-4xl bg-popover text-foreground shadow-raised",
              className,
            )}
            data-slot="family-drawer"
            data-state={open ? "open" : "closed"}
            initial={false}
            style={
              {
                "--family-drawer-open-width": `${openWidth}px`,
                "--family-drawer-closed-height": `${closedHeight}px`,
                ...style,
              } as CSSProperties
            }
            transition={SURFACE_SPRING}
            {...props}
          >
            {children}
          </motion.div>
        </FrameContext>
      </DrawerContext>
    </MotionConfig>
  );
}

export function FamilyDrawerTrigger({
  className,
  children,
  onClick,
  ...props
}: ElementProps<"button">) {
  const { open, setOpen } = useFamilyDrawer();
  const { reduceMotion } = useFrame();

  return (
    <AnimatePresence initial={false}>
      {!open ? (
        <motion.button
          animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
          className={cn(
            "absolute inset-x-0 bottom-0 flex h-(--family-drawer-closed-height) items-center justify-center rounded-full px-5 text-sm font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset",
            className,
          )}
          data-slot="family-drawer-trigger"
          exit={{ opacity: 0, scale: 0.96, filter: "blur(4px)" }}
          initial={{ opacity: 0, scale: 0.96, filter: "blur(4px)" }}
          key="closed"
          onClick={(event) => {
            onClick?.(event);
            setOpen(true);
          }}
          transition={{
            duration: reduceMotion ? 0.01 : 0.18,
            ease: [0.165, 0.84, 0.44, 1],
          }}
          type="button"
          whileTap={{ scale: 0.94 }}
          {...props}
        >
          {children}
        </motion.button>
      ) : null}
    </AnimatePresence>
  );
}

// The invisible copy is measured so the surface resizes before the visible page swaps.
export function FamilyDrawerContent({ className, children, ...props }: ElementProps<"div">) {
  const { open } = useFamilyDrawer();
  const { measure } = useFrame();

  return (
    <>
      <div
        aria-hidden="true"
        className="pointer-events-none invisible absolute inset-x-0 top-0 w-(--family-drawer-open-width)"
        inert
        ref={measure}
      >
        <MeasuringContext value={true}>{children}</MeasuringContext>
      </div>

      <AnimatePresence initial={false}>
        {open ? (
          <motion.div
            animate={{ ...CONTENT.shown, transition: CONTENT.enter }}
            className={cn(
              "absolute top-0 left-1/2 w-(--family-drawer-open-width) -translate-x-1/2",
              className,
            )}
            data-slot="family-drawer-content"
            exit={{ ...CONTENT.hidden, transition: CONTENT.exit }}
            initial={CONTENT.hidden}
            key="open"
            {...props}
          >
            {children}
          </motion.div>
        ) : null}
      </AnimatePresence>
    </>
  );
}

export function FamilyDrawerClose({
  className,
  children,
  onClick,
  ...props
}: ComponentProps<"button">) {
  const measuring = use(MeasuringContext);
  const { setOpen } = useFamilyDrawer();

  if (measuring) return null;

  return (
    <button
      aria-label="Close"
      className={cn(
        "absolute top-5 right-6 z-10 flex size-7 items-center justify-center rounded-full bg-control text-control-foreground hover:bg-control-hover",
        PRESS,
        className,
      )}
      data-slot="family-drawer-close"
      onClick={(event) => {
        onClick?.(event);
        setOpen(false);
      }}
      type="button"
      {...props}
    >
      {children ?? <CloseSmallIcon />}
    </button>
  );
}

export function FamilyDrawerPages({ className, children, ...props }: ComponentProps<"div">) {
  const measuring = use(MeasuringContext);
  const { page } = useFamilyDrawer();
  const { fadeDuration, reduceMotion } = useFrame();
  const current = <ActivePageContext value={page}>{children}</ActivePageContext>;

  return (
    <div className={cn("px-5 pt-2 pb-5", className)} data-slot="family-drawer-pages" {...props}>
      {measuring ? (
        current
      ) : (
        <AnimatePresence initial={false} mode="popLayout">
          <motion.div
            animate={{ filter: "blur(0px)", opacity: 1 }}
            exit={{ filter: "blur(3px)", opacity: 0 }}
            initial={{ filter: "blur(3px)", opacity: 0 }}
            key={page}
            transition={{
              duration: reduceMotion ? 0.01 : fadeDuration,
              ease: [0.25, 1, 0.5, 1],
            }}
          >
            {current}
          </motion.div>
        </AnimatePresence>
      )}
    </div>
  );
}

export function FamilyDrawerPage({ value, children }: { value: string; children: ReactNode }) {
  const active = use(ActivePageContext);

  if (active === null)
    throw new Error("<FamilyDrawerPage> must be rendered inside <FamilyDrawerPages>.");
  return value === active ? children : null;
}

export function FamilyDrawerHeader({ className, ...props }: ComponentProps<"header">) {
  return <header className={className} data-slot="family-drawer-header" {...props} />;
}

export function FamilyDrawerTitle({ className, children, ...props }: ComponentProps<"h4">) {
  return (
    <h4
      className={cn("text-lg leading-snug font-semibold", className)}
      data-slot="family-drawer-title"
      {...props}
    >
      {children}
    </h4>
  );
}

export function FamilyDrawerDescription({ className, ...props }: ComponentProps<"p">) {
  return (
    <p
      className={cn("mt-2 text-sm leading-snug font-medium text-muted-foreground", className)}
      data-slot="family-drawer-description"
      {...props}
    />
  );
}

export function FamilyDrawerItem({ className, ...props }: ComponentProps<"button">) {
  return (
    <button
      className={cn(
        PRESSABLE,
        "gap-3 rounded-xl px-4 text-left bg-control hover:bg-control-hover",
        className,
      )}
      data-slot="family-drawer-item"
      type="button"
      {...props}
    />
  );
}

export function FamilyDrawerFooter({ className, ...props }: ComponentProps<"div">) {
  return (
    <div className={cn("mt-6 flex gap-3", className)} data-slot="family-drawer-footer" {...props} />
  );
}

export function FamilyDrawerButton({ className, ...props }: ComponentProps<"button">) {
  return (
    <button
      className={cn(
        PRESSABLE,
        "justify-center gap-2 rounded-full px-4 bg-control hover:bg-control-hover",
        className,
      )}
      data-slot="family-drawer-button"
      type="button"
      {...props}
    />
  );
}