Inspector
A right-edge hover-reveal panel for properties / settings sidebars.
Hover the right edge →
import { Palette } from '@untitledui-pro/icons/solid';
import { Input } from '@/components/input';
import { Inspector } from '@/components/inspector';
export default function InspectorPreview() {
return (
<div className="relative h-[480px] w-full overflow-hidden rounded-xl border border-border bg-surface-sunken">
<div className="grid h-full place-items-center text-foreground-secondary text-sm">
Hover the right edge →
</div>
<Inspector defaultPinned width="18rem">
<Inspector.Handle />
<Inspector.Panel>
<Inspector.Header title="Page style" icon={<Palette />} />
<Inspector.Content>
<Inspector.Section label="Background">
<Inspector.Field label="Color">
<Input className="w-32" defaultValue="#ffffff" />
</Inspector.Field>
<Inspector.Field label="Padding">
<Input className="w-20" defaultValue="0" />
</Inspector.Field>
</Inspector.Section>
<Inspector.Section label="Body">
<Inspector.Field label="Text">
<Input className="w-32" defaultValue="#000000" />
</Inspector.Field>
<Inspector.Field label="Width">
<Input className="w-20" defaultValue="600" />
</Inspector.Field>
<Inspector.Field label="Radius">
<Input className="w-20" defaultValue="8" />
</Inspector.Field>
</Inspector.Section>
</Inspector.Content>
</Inspector.Panel>
</Inspector>
</div>
);
}
export const meta = {
layout: 'padded',
}; Dependencies
Source Code
'use client';
import { Pin01, Pin02, X } from '@untitledui-pro/icons/solid';
import { createContext, use, useState } from 'react';
import { IconButton } from '@/components/button';
import { cn } from '@/lib/utils/classnames';
interface InspectorContextValue {
pinned: boolean;
setPinned: (pinned: boolean) => void;
hovered: boolean;
setHovered: (hovered: boolean) => void;
width: string;
height: string;
}
const InspectorContext = createContext<InspectorContextValue | null>(null);
const useInspector = () => {
const ctx = use(InspectorContext);
if (!ctx) throw new Error('Inspector parts must be inside <Inspector>');
return ctx;
};
interface InspectorProps extends React.ComponentPropsWithRef<'section'> {
/** Open state when pinned. Uncontrolled if omitted. */
defaultPinned?: boolean;
/** Width of the panel when open. Default 20rem. */
width?: string;
/** Height of the panel. Default fills the container minus 2rem. */
height?: string;
}
/**
* Right-edge hover-reveal panel. Renders three parts:
* - a thin handle on the viewport's right edge
* - the floating panel itself, translated off-screen by default
* - a backdrop-free hover region that keeps the panel open while the
* pointer is over it
*
* Hovering the handle slides the panel in. Clicking the pin in the panel
* header keeps it open even after the pointer leaves.
*
* Place once inside an `AppShell` (or any positioned ancestor). The shell's
* main content stays unaffected — the panel floats over it.
*/
const Inspector = ({
ref,
className,
defaultPinned = false,
width = '20rem',
height = 'calc(100% - 2rem)',
children,
...rest
}: InspectorProps) => {
const [pinned, setPinned] = useState(defaultPinned);
const [hovered, setHovered] = useState(false);
const open = pinned || hovered;
return (
<InspectorContext
value={{ pinned, setPinned, hovered, setHovered, width, height }}
>
<section
ref={ref}
data-inspector
data-open={open ? '' : undefined}
data-pinned={pinned ? '' : undefined}
aria-label="Inspector"
style={
{
'--inspector-w': width,
'--inspector-h': height,
} as React.CSSProperties
}
className={cn(
'pointer-events-none absolute inset-y-0 right-0 z-30 flex items-center pr-3',
className
)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
{...rest}
>
{children}
</section>
</InspectorContext>
);
};
/**
* The small vertical pill on the right edge that signals "hover me to reveal
* the panel". It's still visible (subtle) when the panel is open so users
* know what they're hovering.
*/
const InspectorHandle = ({
ref,
className,
...rest
}: React.ComponentPropsWithRef<'div'>) => {
const { pinned } = useInspector();
return (
<div
ref={ref}
aria-hidden="true"
className={cn(
// Visible pill — small, inset from the viewport edge.
'pointer-events-auto absolute top-1/2 right-3 h-24 w-1.5 -translate-y-1/2 rounded-full bg-foreground/20',
'transition-opacity duration-(--duration-hover) ease-(--ease)',
// Invisible hit zone via ::after — extends far beyond the visible
// pill so the user doesn't lose hover when the pointer wobbles off
// the 1.5px line on its way to the panel.
"after:absolute after:-inset-x-4 after:-inset-y-16 after:content-['']",
pinned && 'opacity-0',
className
)}
{...rest}
/>
);
};
interface InspectorPanelProps extends React.ComponentPropsWithRef<'aside'> {}
const InspectorPanel = ({
ref,
className,
children,
...rest
}: InspectorPanelProps) => {
const { hovered, pinned } = useInspector();
const open = pinned || hovered;
return (
<aside
ref={ref}
data-state={open ? 'open' : 'closed'}
className={cn(
'pointer-events-auto flex h-(--inspector-h) w-(--inspector-w) flex-col overflow-hidden rounded-2xl border border-border bg-surface-raised shadow-lg',
'transition-transform duration-(--duration-hover) ease-(--ease)',
'data-[state=closed]:translate-x-[calc(100%+1rem)]',
'data-[state=open]:translate-x-0',
className
)}
{...rest}
>
{children}
</aside>
);
};
interface InspectorHeaderProps
extends Omit<React.ComponentPropsWithRef<'div'>, 'title'> {
title: React.ReactNode;
icon?: React.ReactNode;
/** Show the pin toggle. Default `true`. */
showPin?: boolean;
/** Show the close button (which un-pins). Default `false`. */
showClose?: boolean;
}
const InspectorHeader = ({
ref,
className,
title,
icon,
showPin = true,
showClose = false,
...rest
}: InspectorHeaderProps) => {
const { pinned, setPinned } = useInspector();
return (
<div
ref={ref}
className={cn(
'flex shrink-0 items-center gap-2 border-border border-b px-4 py-3',
className
)}
{...rest}
>
{icon && (
<span className="grid size-4 shrink-0 place-items-center text-foreground-secondary [&>svg]:size-4">
{icon}
</span>
)}
<span className="flex-1 truncate font-semibold text-foreground text-sm">
{title}
</span>
{showPin && (
<IconButton
variant="ghost"
size="xs"
aria-label={pinned ? 'Unpin' : 'Pin open'}
aria-pressed={pinned}
onClick={() => setPinned(!pinned)}
>
{pinned ? <Pin02 /> : <Pin01 />}
</IconButton>
)}
{showClose && (
<IconButton
variant="ghost"
size="xs"
aria-label="Close"
onClick={() => setPinned(false)}
>
<X />
</IconButton>
)}
</div>
);
};
const InspectorContent = ({
ref,
className,
...rest
}: React.ComponentPropsWithRef<'div'>) => (
<div
ref={ref}
className={cn('flex-1 overflow-y-auto p-4', className)}
{...rest}
/>
);
interface InspectorSectionProps extends React.ComponentPropsWithRef<'section'> {
label: React.ReactNode;
}
const InspectorSection = ({
ref,
className,
label,
children,
...rest
}: InspectorSectionProps) => (
<section
ref={ref}
className={cn(
'flex flex-col gap-3 border-border border-t pt-5 first-of-type:border-t-0 first-of-type:pt-0',
'mt-5 first-of-type:mt-0',
className
)}
{...rest}
>
<p className="font-semibold text-foreground text-sm">{label}</p>
<div className="flex flex-col gap-3">{children}</div>
</section>
);
interface InspectorFieldProps extends React.ComponentPropsWithRef<'div'> {
label: React.ReactNode;
}
const InspectorField = ({
ref,
className,
label,
children,
...rest
}: InspectorFieldProps) => (
<div
ref={ref}
className={cn('flex items-center justify-between gap-3', className)}
{...rest}
>
<label className="text-foreground-secondary text-sm">{label}</label>
<div className="flex min-w-0 flex-shrink-0 items-center gap-1">
{children}
</div>
</div>
);
const CompoundInspector = Object.assign(Inspector, {
Handle: InspectorHandle,
Panel: InspectorPanel,
Header: InspectorHeader,
Content: InspectorContent,
Section: InspectorSection,
Field: InspectorField,
});
export type {
InspectorFieldProps,
InspectorHeaderProps,
InspectorPanelProps,
InspectorProps,
InspectorSectionProps,
};
export { CompoundInspector as Inspector, useInspector }; Inspector is a floating right-edge panel that slides in on hover and stays
until the pointer leaves. A pin button locks it open. Use it for property
inspectors, settings sidebars, or any contextual controls that shouldn’t
permanently consume layout space.
The panel renders as a fixed-position overlay — main content beneath it never
shifts. Place once inside an AppShell (or any positioned ancestor).
Anatomy
<Inspector>
<Inspector.Handle />
<Inspector.Panel>
<Inspector.Header title="Page style" />
<Inspector.Content>
<Inspector.Section label="Background">
<Inspector.Field label="Color">
<Input />
</Inspector.Field>
</Inspector.Section>
</Inspector.Content>
</Inspector.Panel>
</Inspector>
Behavior
- Default state: panel translated off-screen, only
Handlevisible as a thin pill on the right edge. - Hover: hovering the handle or panel slides the panel in via
--duration-hover. - Pin: clicking the pin in
Inspector.Headerkeeps the panel open. Hover state is then ignored. - Unpin / close: clicking the pin again (or
showClose’s X) un-pins. The panel hides as soon as the pointer leaves.
The panel respects prefers-reduced-motion via the shared family token.
API Reference
Inspector
Extends the div element.
| Prop | Default | Type |
|---|---|---|
defaultPinned | false | boolean |
width | '20rem' | string |
Inspector.Header
| Prop | Default | Type |
|---|---|---|
title * | - | ReactNode |
icon | - | ReactNode |
showPin | true | boolean |
showClose | false | boolean |
Inspector.Section
| Prop | Default | Type |
|---|---|---|
label * | - | ReactNode |
Inspector.Field
| Prop | Default | Type |
|---|---|---|
label * | - | ReactNode |
Examples
Default
Hover the right edge →
import { Palette } from '@untitledui-pro/icons/solid';
import { Input } from '@/components/input';
import { Inspector } from '@/components/inspector';
export default function InspectorPreview() {
return (
<div className="relative h-[480px] w-full overflow-hidden rounded-xl border border-border bg-surface-sunken">
<div className="grid h-full place-items-center text-foreground-secondary text-sm">
Hover the right edge →
</div>
<Inspector defaultPinned width="18rem">
<Inspector.Handle />
<Inspector.Panel>
<Inspector.Header title="Page style" icon={<Palette />} />
<Inspector.Content>
<Inspector.Section label="Background">
<Inspector.Field label="Color">
<Input className="w-32" defaultValue="#ffffff" />
</Inspector.Field>
<Inspector.Field label="Padding">
<Input className="w-20" defaultValue="0" />
</Inspector.Field>
</Inspector.Section>
<Inspector.Section label="Body">
<Inspector.Field label="Text">
<Input className="w-32" defaultValue="#000000" />
</Inspector.Field>
<Inspector.Field label="Width">
<Input className="w-20" defaultValue="600" />
</Inspector.Field>
<Inspector.Field label="Radius">
<Input className="w-20" defaultValue="8" />
</Inspector.Field>
</Inspector.Section>
</Inspector.Content>
</Inspector.Panel>
</Inspector>
</div>
);
}
export const meta = {
layout: 'padded',
}; Previous
Input
Next
Kbd