← All components

SlideOver

A glass panel that springs in from the right edge and sits inset from it.

The idea

Inset from the edge rather than flush against it, so it reads as something laid over the page, not a column the page was cut down to make room for. In, it springs 32px; out, it eases 24px and fades, quicker and without the bounce, because leaving is not an event. Escape closes it, and focus moves into it on open so the keyboard lands where the eyes do.

Composes ChatComposer.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

SlideOver — usage11 lines
import { SlideOver, ChatComposer } from "mihirmodi-ui";

<SlideOver
  open={!!thread}
  onClose={() => setThread(null)}
  eyebrow="Thread"
  title={thread?.quote}
  footer={<ChatComposer surface="plain" value={q} onChange={setQ} onSubmit={reply} />}
>
  <Transcript messages={thread?.messages} />
</SlideOver>

Props

PropNotes
open / onCloseboolean / () => voidControlled. Escape, the close button and the scrim all call `onClose`.
title / eyebrowReactNodeThe heading, and a small line above it.
footerReactNodePinned to the bottom — a composer.
actionsReactNodeHeader controls beside the close button.
widthnumberCapped at the viewport less its inset.
scrimbooleanDim the page and close on a press outside. Off for a panel that works alongside the page.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/SlideOver.tsx116 lines
"use client";

import { useEffect, useId, useRef, type ReactNode } from "react";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { CloseIcon } from "../lib/glyph.js";
import { CLOSE } from "../lib/motion.js";

export type SlideOverProps = {
  open: boolean;
  onClose: () => void;
  title: ReactNode;
  /** A line above the title — "Thread", a breadcrumb. */
  eyebrow?: ReactNode;
  children: ReactNode;
  /** Pinned to the bottom — a composer. */
  footer?: ReactNode;
  /** Header controls beside the close button. */
  actions?: ReactNode;
  width?: number;
  /** Dim the page a touch and close on a press outside. Off for a panel that works alongside the page. */
  scrim?: boolean;
};

/**
 * A glass panel that slides in from the right edge and sits inset from it, so
 * it reads as something laid over the page rather than a column the page was
 * cut down to make room for.
 *
 * In, it springs 32px; out, it eases 24px and fades — quicker, and without the
 * bounce, because leaving is not an event. Escape closes it and focus moves
 * into it on open, so the keyboard lands where the eyes do.
 */
export function SlideOver({
  open,
  onClose,
  title,
  eyebrow,
  children,
  footer,
  actions,
  width = 440,
  scrim = true,
}: SlideOverProps) {
  const reduce = useReducedMotion();
  const panel = useRef<HTMLElement>(null);
  const titleId = useId();

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    window.addEventListener("keydown", onKey);
    panel.current?.focus();
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onClose]);

  return (
    <LazyMotion features={domAnimation} strict>
      <AnimatePresence>
        {open && scrim && (
          <m.button
            key="scrim"
            type="button"
            aria-label="Close panel"
            tabIndex={-1}
            onClick={onClose}
            className="fixed inset-0 z-[55] cursor-default bg-ink/[0.06]"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: CLOSE }}
            transition={{ duration: 0.2 }}
          />
        )}
        {open && (
          <m.aside
            key="panel"
            ref={panel}
            tabIndex={-1}
            role="dialog"
            aria-modal={scrim || undefined}
            aria-labelledby={titleId}
            className="glass-panel dock-shadow fixed bottom-3 right-3 top-3 z-[56] flex flex-col overflow-hidden rounded-[20px] border-[0.5px] border-gray-alpha-400 outline-none"
            style={{ width: `min(${width}px, calc(100vw - 24px))` }}
            initial={reduce ? { opacity: 0 } : { opacity: 0, x: 32 }}
            animate={{ opacity: 1, x: 0 }}
            exit={reduce ? { opacity: 0, transition: CLOSE } : { opacity: 0, x: 24, transition: { duration: 0.18, ease: "easeIn" } }}
            transition={
              reduce ? { duration: 0.15 } : { x: { type: "spring", bounce: 0.12, duration: 0.5 }, opacity: { duration: 0.18, ease: "easeOut" } }
            }
          >
            <header className="flex shrink-0 items-start gap-3 border-b-[0.5px] border-gray-alpha-400 px-4 pb-3.5 pt-4">
              <div className="min-w-0 flex-1">
                {eyebrow && <p className="text-[12px] text-faint">{eyebrow}</p>}
                <h2 id={titleId} className={`text-[15px] font-medium leading-[1.4] text-gray-1000 ${eyebrow ? "mt-1" : ""}`}>
                  {title}
                </h2>
              </div>
              <div className="flex shrink-0 items-center gap-1">
                {actions}
                <button
                  type="button"
                  onClick={onClose}
                  aria-label="Close"
                  className="flex h-8 w-8 items-center justify-center rounded-[10px] text-quiet transition-colors duration-150 ease-geist hover:bg-gray-alpha-100 hover:text-gray-1000"
                >
                  <CloseIcon />
                </button>
              </div>
            </header>
            <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">{children}</div>
            {footer && <div className="shrink-0 p-3 pt-2">{footer}</div>}
          </m.aside>
        )}
      </AnimatePresence>
    </LazyMotion>
  );
}