HextorUI
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-panel

Source

"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