ui
Peek panel
Row detail beside the list instead of on top of it, with the list still arrow-key navigable.
npx shadcn@latest add @hextor/peek-panelSource
"use client";
import * as React from "react";
import { useEffect } from "react";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { cn } from "@/registry/hextor/lib/utils";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useMediaQuery } from "@/registry/hextor/hooks/use-media-query";
import { resolveLabels, type PeekPanelLabels } from "@/registry/hextor/ui/peek-panel-labels";
export interface PeekPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
children: React.ReactNode;
/** Move to the adjacent row without closing the panel. Omit to disable row-to-row navigation. */
onNavigate?: (direction: "next" | "prev") => void;
hasNext?: boolean;
hasPrev?: boolean;
/** Extra header controls (e.g. "open full page") rendered before the close button. */
actions?: React.ReactNode;
/** Below this width the panel becomes a full sheet instead of a side pane. @default 768 */
narrowBreakpoint?: number;
labels?: Partial<PeekPanelLabels>;
className?: string;
}
function isTextInput(el: Element | null): boolean {
if (!el) return false;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return true;
return (el as HTMLElement).isContentEditable === true;
}
/**
* A row's detail rendered BESIDE the list, not instead of it — Linear's
* "peek": place this next to whatever list it previews in a flex/grid
* layout the consumer owns, and it stays a plain, non-modal `<aside>`, on
* purpose. The failure mode it's built against is a peek panel that steals
* focus into itself: the moment focus leaves the list, arrow-key
* navigation between rows stops working, and "beside the list without
* losing the list" becomes a lie. So this panel never calls `.focus()` on
* itself, and next/prev navigation is driven by a document-level listener
* (guarded against firing while a text field is focused) rather than by
* anything inside the panel needing focus first.
*
* Below `narrowBreakpoint`, there's no room to show list and detail at
* once, so the same content renders inside a `Sheet` instead — there,
* trapping focus is correct, because the list really is off-screen.
*/
export function PeekPanel({
open,
onOpenChange,
title,
children,
onNavigate,
hasNext = false,
hasPrev = false,
actions,
narrowBreakpoint = 768,
labels: labelsProp,
className,
}: PeekPanelProps) {
const labels = resolveLabels(labelsProp);
const isNarrow = useMediaQuery(`(max-width: ${narrowBreakpoint}px)`);
useEffect(() => {
if (!open) return;
function onKeyDown(event: KeyboardEvent) {
if (isTextInput(document.activeElement)) return;
if (event.key === "Escape") {
onOpenChange(false);
return;
}
if ((event.key === "ArrowDown" || event.key === "j") && hasNext && onNavigate) {
event.preventDefault();
onNavigate("next");
}
if ((event.key === "ArrowUp" || event.key === "k") && hasPrev && onNavigate) {
event.preventDefault();
onNavigate("prev");
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, hasNext, hasPrev, onNavigate, onOpenChange]);
const titleText = typeof title === "string" ? title : labels.title;
const body = (
<div data-slot="peek-panel-body" className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-4 py-3">
<div className="min-w-0 flex-1 truncate text-sm font-medium">{title}</div>
<div className="flex shrink-0 items-center gap-1">
{onNavigate && (
<>
<Button
variant="ghost"
size="icon-sm"
disabled={!hasPrev}
aria-label={labels.previous}
onClick={() => onNavigate("prev")}
>
<ChevronUp aria-hidden />
</Button>
<Button
variant="ghost"
size="icon-sm"
disabled={!hasNext}
aria-label={labels.next}
onClick={() => onNavigate("next")}
>
<ChevronDown aria-hidden />
</Button>
</>
)}
{actions}
<Button variant="ghost" size="icon-sm" aria-label={labels.close} onClick={() => onOpenChange(false)}>
<X aria-hidden />
</Button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
</div>
);
if (isNarrow) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" showCloseButton={false} className={cn("h-[85vh] gap-0 p-0", className)}>
<SheetHeader className="sr-only">
<SheetTitle>{titleText}</SheetTitle>
</SheetHeader>
{body}
</SheetContent>
</Sheet>
);
}
if (!open) return null;
return (
<section
role="complementary"
aria-label={titleText}
data-slot="peek-panel"
className={cn(
"flex h-full w-full max-w-md flex-col overflow-hidden border-l border-border bg-popover text-popover-foreground",
className,
)}
>
{body}
</section>
);
}
Docs
The panel never calls focus() on itself. That single restraint is the whole interaction: focus stays in the list, so arrow keys keep moving between rows and the peek updates as you go. A panel that grabs focus is a modal wearing a different shape.
Below the narrow breakpoint it falls back to a sheet, because beside-the-list needs a beside.
Dependencies
lucide-reactsheetbutton@hextor/app-hooks