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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| open / onCloseboolean / () => void | boolean / () => void | Controlled. Escape, the close button and the scrim all call `onClose`. |
| title / eyebrowReactNode | ReactNode | The heading, and a small line above it. |
| footerReactNode | ReactNode | Pinned to the bottom — a composer. |
| actionsReactNode | ReactNode | Header controls beside the close button. |
| widthnumber | number= 440 | Capped at the viewport less its inset. |
| scrimboolean | boolean= true | Dim 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.
"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>
);
}