# Inspector Panel

> A non-modal right-side inspector with Details and Activity tabs, property rows and an optional pin.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/inspector-panel
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/inspector-panel.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/inspector-panel-demo.tsx"
"use client"

import * as React from "react"
import { FileImage, FileText, FileVideo } from "lucide-react"

import { InspectorPanel } from "@/components/inspector-panel"
import { cn } from "@/lib/utils"

const files = [
  {
    id: "brand",
    name: "brand-guidelines.pdf",
    kind: "PDF document",
    size: "4.2 MB",
    owner: "Ada Lovelace",
    modified: "Today, 09:41",
    tags: ["Brand", "Final"],
    Icon: FileText,
    activity: [
      { id: "a1", actor: "Ada", text: "uploaded version 3", time: "Today, 09:41" },
      { id: "a2", actor: "Grace", text: "left a comment on page 12", time: "Yesterday, 16:20" },
      { id: "a3", actor: "Alan", text: "viewed the file", time: "Mon, 11:05" },
    ],
  },
  {
    id: "hero",
    name: "hero-banner.png",
    kind: "PNG image",
    size: "1.8 MB",
    owner: "Grace Hopper",
    modified: "Yesterday, 18:02",
    tags: ["Marketing"],
    Icon: FileImage,
    activity: [
      { id: "b1", actor: "Grace", text: "replaced the image", time: "Yesterday, 18:02" },
      { id: "b2", actor: "Ada", text: "approved the banner", time: "Yesterday, 18:30" },
    ],
  },
  {
    id: "teaser",
    name: "launch-teaser.mp4",
    kind: "MP4 video",
    size: "86 MB",
    owner: "Alan Turing",
    modified: "Mon, 14:15",
    tags: ["Video", "Draft"],
    Icon: FileVideo,
    activity: [{ id: "c1", actor: "Alan", text: "exported the teaser", time: "Mon, 14:15" }],
  },
]

export default function InspectorPanelDemo() {
  const [selected, setSelected] = React.useState(files[0].id)
  const [open, setOpen] = React.useState(false)
  const [pinned, setPinned] = React.useState(false)
  const file = files.find((f) => f.id === selected) ?? files[0]

  return (
    <div className="relative h-[480px] w-full overflow-hidden rounded-2xl border bg-card">
      <div className={cn("grid content-start gap-4 p-5 transition-[padding] duration-300 sm:p-6", open && pinned && "sm:pr-[384px]")}>
        <div>
          <p className="text-sm font-semibold">Assets</p>
          <p className="text-sm text-muted-foreground">Select a file to inspect it. Pin the panel to keep it open.</p>
        </div>
        <ul className="grid gap-2">
          {files.map(({ id, name, size, modified, Icon }) => (
            <li key={id}>
              <button
                type="button"
                data-inspector-ignore
                aria-pressed={open && selected === id}
                onClick={() => {
                  setSelected(id)
                  setOpen(true)
                }}
                className={cn(
                  "flex w-full min-w-0 items-center gap-3 rounded-xl border bg-background p-3 text-left transition-colors hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none",
                  open && selected === id && "border-primary/50 bg-accent"
                )}
              >
                <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
                  <Icon className="size-5" aria-hidden />
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-sm font-medium">{name}</span>
                  <span className="block truncate text-xs text-muted-foreground">
                    {size} · {modified}
                  </span>
                </span>
              </button>
            </li>
          ))}
        </ul>
      </div>

      <InspectorPanel
        open={open}
        onOpenChange={setOpen}
        pinned={pinned}
        onPinnedChange={setPinned}
        title={file.name}
        subtitle={file.kind}
        icon={<file.Icon />}
        properties={[
          { label: "Type", value: file.kind },
          { label: "Size", value: file.size },
          { label: "Owner", value: file.owner },
          { label: "Modified", value: file.modified },
          {
            label: "Tags",
            value: (
              <span className="flex flex-wrap justify-end gap-1.5">
                {file.tags.map((tag) => (
                  <span key={tag} className="rounded-md bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
                    {tag}
                  </span>
                ))}
              </span>
            ),
          },
        ]}
        activity={file.activity}
      />
    </div>
  )
}
```

## Source

```tsx title="components/inspector-panel.tsx"
"use client"

import * as React from "react"
import { Pin, PinOff, X } from "lucide-react"

import { cn } from "@/lib/utils"

export interface InspectorProperty {
  label: string
  value: React.ReactNode
}

export interface InspectorActivity {
  id: string
  actor: string
  text: string
  time: string
}

export interface InspectorPanelProps extends Omit<React.ComponentProps<"aside">, "title"> {
  open: boolean
  onOpenChange: (open: boolean) => void
  title: React.ReactNode
  subtitle?: React.ReactNode
  icon?: React.ReactNode
  properties: InspectorProperty[]
  activity?: InspectorActivity[]
  /** Show the pin button. Pinned panels ignore outside clicks. */
  pinnable?: boolean
  pinned?: boolean
  defaultPinned?: boolean
  onPinnedChange?: (pinned: boolean) => void
  /** Panel width in px on screens wider than 640px; below that it is a full-width sheet */
  width?: number
  /** Use viewport positioning instead of the nearest positioned ancestor */
  fixed?: boolean
}

const tabs = ["details", "activity"] as const
type Tab = (typeof tabs)[number]

// Non-modal: the page behind stays interactive. Clicks outside close an unpinned panel
// unless they land on an element marked with data-inspector-ignore (e.g. the rows that open it).
export function InspectorPanel({
  open,
  onOpenChange,
  title,
  subtitle,
  icon,
  properties,
  activity = [],
  pinnable = true,
  pinned: pinnedProp,
  defaultPinned = false,
  onPinnedChange,
  width = 360,
  fixed = false,
  className,
  style,
  ...props
}: InspectorPanelProps) {
  const [innerPinned, setInnerPinned] = React.useState(defaultPinned)
  const [tab, setTab] = React.useState<Tab>("details")
  const pinned = pinnedProp ?? innerPinned
  const panel = React.useRef<HTMLElement>(null)
  const opener = React.useRef<HTMLElement | null>(null)
  const wasOpen = React.useRef(false)
  const baseId = React.useId()
  const titleId = `${baseId}-title`

  const close = React.useCallback(() => {
    onOpenChange(false)
    opener.current?.focus()
    opener.current = null
  }, [onOpenChange])

  React.useEffect(() => {
    if (open && !wasOpen.current) {
      const active = document.activeElement
      opener.current = active instanceof HTMLElement && !panel.current?.contains(active) ? active : null
      panel.current?.focus({ preventScroll: true })
    }
    wasOpen.current = open
  }, [open])

  React.useEffect(() => {
    if (!open || pinned) return
    const onPointerDown = (event: PointerEvent) => {
      const target = event.target as Element
      if (panel.current?.contains(target) || target.closest("[data-inspector-ignore]")) return
      onOpenChange(false)
    }
    document.addEventListener("pointerdown", onPointerDown)
    return () => document.removeEventListener("pointerdown", onPointerDown)
  }, [open, pinned, onOpenChange])

  function togglePin() {
    const next = !pinned
    if (pinnedProp === undefined) setInnerPinned(next)
    onPinnedChange?.(next)
  }

  function onTabKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
    const index = tabs.indexOf(tab)
    let next: number | null = null
    if (event.key === "ArrowRight") next = (index + 1) % tabs.length
    else if (event.key === "ArrowLeft") next = (index - 1 + tabs.length) % tabs.length
    else if (event.key === "Home") next = 0
    else if (event.key === "End") next = tabs.length - 1
    if (next === null) return
    event.preventDefault()
    setTab(tabs[next])
    document.getElementById(`${baseId}-tab-${tabs[next]}`)?.focus()
  }

  const iconButton =
    "flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"

  return (
    <div className={cn("pointer-events-none inset-0 z-30 overflow-hidden", fixed ? "fixed" : "absolute")}>
      <aside
        ref={panel}
        tabIndex={-1}
        aria-labelledby={titleId}
        aria-hidden={!open}
        onKeyDown={(event) => {
          if (event.key === "Escape" && !event.defaultPrevented) close()
        }}
        style={{ "--inspector-width": `${width}px`, ...style } as React.CSSProperties}
        className={cn(
          "pointer-events-auto absolute inset-y-0 right-0 flex w-full flex-col border-l bg-background text-foreground shadow-xl outline-none sm:w-(--inspector-width) sm:max-w-full",
          "transition-[translate,visibility] duration-300 ease-[cubic-bezier(.32,.72,0,1)] motion-reduce:transition-none",
          open ? "visible translate-x-0" : "invisible translate-x-full",
          className
        )}
        {...props}
      >
        <header className="flex shrink-0 items-start gap-3 border-b px-4 py-3.5">
          {icon && <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary [&_svg]:size-5">{icon}</span>}
          <div className="min-w-0 flex-1">
            <h2 id={titleId} className="truncate text-sm font-semibold">
              {title}
            </h2>
            {subtitle && <p className="truncate text-xs text-muted-foreground">{subtitle}</p>}
          </div>
          {pinnable && (
            <button
              type="button"
              aria-pressed={pinned}
              aria-label={pinned ? "Unpin panel" : "Pin panel"}
              onClick={togglePin}
              className={cn(iconButton, "hidden sm:flex", pinned && "bg-accent text-foreground")}
            >
              {pinned ? <PinOff className="size-4" aria-hidden /> : <Pin className="size-4" aria-hidden />}
            </button>
          )}
          <button type="button" aria-label="Close inspector" onClick={close} className={iconButton}>
            <X className="size-4" aria-hidden />
          </button>
        </header>

        <div role="tablist" aria-label="Inspector sections" className="flex shrink-0 gap-1 border-b px-3">
          {tabs.map((value) => {
            const active = value === tab
            return (
              <button
                key={value}
                type="button"
                role="tab"
                id={`${baseId}-tab-${value}`}
                aria-selected={active}
                aria-controls={`${baseId}-panel-${value}`}
                tabIndex={active ? 0 : -1}
                onClick={() => setTab(value)}
                onKeyDown={onTabKeyDown}
                className={cn(
                  "relative h-10 rounded-t-md px-3 text-sm font-medium transition-colors focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-ring/40 focus-visible:outline-none",
                  active
                    ? "text-foreground after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:rounded-full after:bg-primary"
                    : "text-muted-foreground hover:text-foreground"
                )}
              >
                {value === "details" ? "Details" : "Activity"}
              </button>
            )
          })}
        </div>

        <div className="min-h-0 flex-1 overflow-y-auto">
          <div role="tabpanel" id={`${baseId}-panel-details`} aria-labelledby={`${baseId}-tab-details`} hidden={tab !== "details"}>
            <dl className="divide-y px-4">
              {properties.map((property) => (
                <div key={property.label} className="flex min-w-0 items-start justify-between gap-4 py-3 text-sm">
                  <dt className="w-28 shrink-0 text-muted-foreground">{property.label}</dt>
                  <dd className="min-w-0 flex-1 text-right font-medium break-words">{property.value}</dd>
                </div>
              ))}
            </dl>
          </div>
          <div role="tabpanel" id={`${baseId}-panel-activity`} aria-labelledby={`${baseId}-tab-activity`} hidden={tab !== "activity"}>
            {activity.length === 0 ? (
              <p className="p-4 text-sm text-muted-foreground">No activity yet.</p>
            ) : (
              <ol className="grid gap-4 p-4">
                {activity.map((entry) => (
                  <li key={entry.id} className="relative flex gap-3 text-sm">
                    <span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary/70" aria-hidden />
                    <div className="min-w-0">
                      <p>
                        <span className="font-medium">{entry.actor}</span> <span className="text-muted-foreground">{entry.text}</span>
                      </p>
                      <p className="text-xs text-muted-foreground">{entry.time}</p>
                    </div>
                  </li>
                ))}
              </ol>
            )}
          </div>
        </div>
      </aside>
    </div>
  )
}
```
