Record Detail Sheet · Block
Adaptive Record Detail Sheet
A sectioned record detail surface: a desktop side sheet that becomes a full-screen Touch destination.
Install
One standard shadcn command
The Consumer receives editable source in their application. The Catalog is not a runtime dependency.
pnpm dlx shadcn@latest add https://shadcnpwa.pages.dev/registry/items/adaptive-record-detail-sheet/registry.jsonAI handoff
Prompt for your coding agent
Install Adaptive Record Detail Sheet from the public shadcn registry, then adapt the owned source to my product. Keep the Desktop and Touch presentations, preserve keyboard and screen-reader behavior, and connect my controlled data and actions without adding network requests or persistence.
Full source
Inspect before you install.
Extracted from the Incubator Project's proven record detail surface. Genericized: brand palette replaced with the Consumer's own shadcn theme tokens, incubator copy replaced with neutral English, the icon set swapped to lucide-react, and the policy-specific evidence, contact protection, and consent cards removed.
// src/adaptive-record-detail-sheet.tsx
import { isValidElement, type ComponentProps, type ComponentType, type ReactElement, type ReactNode } from "react";
import { LockIcon, XIcon } from "lucide-react";
import { Sheet } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
AppSheetBody,
AppSheetContent,
AppSheetHeader,
type AppSheetMobilePresentation,
type AppSheetSize,
} from "./app-sheet";
import "./adaptive-record-detail-sheet.css";
export type RecordDetailIcon =
| ReactElement
| ComponentType<{ size?: number; className?: string }>;
function renderDetailIcon(icon?: RecordDetailIcon, size = 15, className?: string) {
if (!icon) return null;
if (isValidElement(icon)) return icon;
const IconComponent = icon as ComponentType<{ size?: number; className?: string }>;
return <IconComponent size={size} className={className} />;
}
/* -------------------------------------------------------------------------------------------------
* Root: RecordDetailSheet
*
* Desktop presents the record as a right side sheet beside the list it came
* from. Touch presents the same record as its own full-height screen, because
* a detail workflow on a handheld is a destination, not an overlay. The switch
* is driven by the stylesheet, so there is no presentation flash on first paint
* and no resize listener to keep in sync.
* -----------------------------------------------------------------------------------------------*/
export interface RecordDetailSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
size?: AppSheetSize;
mobilePresentation?: AppSheetMobilePresentation;
children: ReactNode;
className?: string;
}
export function RecordDetailSheet({
open,
onOpenChange,
size = "md",
mobilePresentation = "fullscreen",
children,
className,
}: RecordDetailSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<AppSheetContent
side="right"
size={size}
mobilePresentation={mobilePresentation}
showCloseButton={false}
className={className}
>
{children}
</AppSheetContent>
</Sheet>
);
}
/* -------------------------------------------------------------------------------------------------
* Header
* -----------------------------------------------------------------------------------------------*/
export interface RecordDetailHeaderProps {
title: ReactNode;
subtitle?: ReactNode;
onClose?: () => void;
closeAriaLabel?: string;
leading?: ReactNode;
trailing?: ReactNode;
className?: string;
}
export function RecordDetailHeader({
title,
subtitle,
onClose,
closeAriaLabel = "Close detail panel",
leading,
trailing,
className,
}: RecordDetailHeaderProps) {
return (
<AppSheetHeader
className={cn(
"flex items-center justify-between gap-3 px-(--app-sheet-space-panel) py-(--app-sheet-space-inline-md) sm:pr-(--app-sheet-space-card)",
className,
)}
>
{/* Balances the centred title on touch, where the close control sits right. */}
{leading ? leading : <span aria-hidden className="size-10 shrink-0 md:hidden" />}
<div className="min-w-0 text-center md:flex-1 md:text-left">
<h2 className="truncate text-base leading-[1.375rem] font-semibold text-foreground">
{title}
</h2>
{subtitle ? (
<p className="mt-0.5 truncate font-mono text-xs font-medium text-muted-foreground">
{subtitle}
</p>
) : null}
</div>
<div className="flex items-center gap-2">
{trailing}
{onClose ? (
<Button
type="button"
variant="outline"
size="icon"
aria-label={closeAriaLabel}
onClick={onClose}
className="size-10 shrink-0 cursor-pointer rounded-full border-border bg-card text-muted-foreground shadow-none hover:bg-accent hover:text-foreground md:rounded-lg md:border-transparent md:bg-transparent"
>
<XIcon size={18} />
</Button>
) : null}
</div>
</AppSheetHeader>
);
}
/* -------------------------------------------------------------------------------------------------
* Body
* -----------------------------------------------------------------------------------------------*/
export function RecordDetailBody({
children,
className,
...props
}: ComponentProps<typeof AppSheetBody>) {
return (
<AppSheetBody className={cn("space-y-6 pb-24 sm:pb-8", className)} {...props}>
{children}
</AppSheetBody>
);
}
/* -------------------------------------------------------------------------------------------------
* Section
* -----------------------------------------------------------------------------------------------*/
export interface RecordDetailSectionProps {
title: ReactNode;
trailing?: ReactNode;
children: ReactNode;
className?: string;
}
export function RecordDetailSection({
title,
trailing,
children,
className,
}: RecordDetailSectionProps) {
return (
<div className={cn("space-y-3.5", className)}>
<div className="flex min-h-8 items-center justify-between gap-3">
{typeof title === "string" ? (
<p
data-slot="record-detail-section-label"
className="text-xs font-semibold tracking-[0.01em] text-muted-foreground uppercase"
>
{title}
</p>
) : (
title
)}
{trailing}
</div>
{children}
</div>
);
}
/* -------------------------------------------------------------------------------------------------
* Inset-grouped card
* -----------------------------------------------------------------------------------------------*/
export interface RecordDetailCardProps {
children: ReactNode;
className?: string;
}
export function RecordDetailCard({ children, className }: RecordDetailCardProps) {
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card shadow-2xs",
className,
)}
>
<div className="divide-y divide-border px-3.5 py-1">{children}</div>
</div>
);
}
/* -------------------------------------------------------------------------------------------------
* Row
* -----------------------------------------------------------------------------------------------*/
export interface RecordDetailRowProps {
label: ReactNode;
value?: ReactNode;
description?: ReactNode;
icon?: RecordDetailIcon;
iconClassName?: string;
/** Renders a placeholder instead of the value; the value is never passed to the DOM. */
masked?: boolean;
maskedPlaceholder?: string;
monospace?: boolean;
action?: ReactNode;
children?: ReactNode;
className?: string;
}
export function RecordDetailRow({
label,
value,
description,
icon,
iconClassName,
masked = false,
maskedPlaceholder = "Hidden until revealed",
monospace = false,
action,
children,
className,
}: RecordDetailRowProps) {
return (
<div className={cn("py-2.5", className)}>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
{action}
</div>
{children ? (
<div className="mt-1">{children}</div>
) : masked ? (
<p className="mt-1 flex items-center gap-1.5 text-[0.8125rem] leading-[1.125rem] text-muted-foreground">
<LockIcon size={14} className="shrink-0" />
<span>{maskedPlaceholder}</span>
</p>
) : (
<div className="mt-1">
<div className="flex items-center gap-2">
{renderDetailIcon(icon, 15, cn("shrink-0 text-muted-foreground", iconClassName))}
<div
className={cn(
"text-[0.8125rem] leading-[1.125rem] font-medium text-foreground",
monospace && "font-mono tabular-nums",
)}
>
{value}
</div>
</div>
{description ? (
<div className="mt-0.5 text-xs font-medium text-muted-foreground">{description}</div>
) : null}
</div>
)}
</div>
);
}
/* -------------------------------------------------------------------------------------------------
* Action tray
* -----------------------------------------------------------------------------------------------*/
export interface RecordDetailActionItem {
label: string;
icon?: RecordDetailIcon;
onClick?: () => void;
href?: string;
external?: boolean;
disabled?: boolean;
ariaLabel?: string;
}
export interface RecordDetailActionTrayProps {
actions?: readonly RecordDetailActionItem[];
children?: ReactNode;
className?: string;
}
export function RecordDetailActionTray({
actions,
children,
className,
}: RecordDetailActionTrayProps) {
if (children) {
return (
<div
className={cn(
"grid grid-cols-2 divide-x divide-border border-t border-border bg-muted/50",
className,
)}
>
{children}
</div>
);
}
if (!actions || actions.length === 0) return null;
const gridColsClass =
actions.length === 1 ? "grid-cols-1" : actions.length === 2 ? "grid-cols-2" : "grid-cols-3";
const itemClasses =
"flex cursor-pointer items-center justify-center gap-2 px-2 py-3 text-xs font-semibold text-foreground transition-colors hover:bg-accent active:bg-accent/80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50";
return (
<div
className={cn(
"grid divide-x divide-border border-t border-border bg-muted/50",
gridColsClass,
className,
)}
>
{actions.map((action) => {
const content = (
<>
{renderDetailIcon(action.icon, 15, "shrink-0 text-muted-foreground")}
<span>{action.label}</span>
</>
);
if (action.href) {
return (
<a
key={action.label}
href={action.href}
target={action.external ? "_blank" : undefined}
rel={action.external ? "noreferrer noopener" : undefined}
aria-label={action.ariaLabel ?? action.label}
className={itemClasses}
>
{content}
</a>
);
}
return (
<button
key={action.label}
type="button"
onClick={action.onClick}
disabled={action.disabled}
aria-label={action.ariaLabel ?? action.label}
className={itemClasses}
>
{content}
</button>
);
})}
</div>
);
}
/* -------------------------------------------------------------------------------------------------
* Footer
* -----------------------------------------------------------------------------------------------*/
export interface RecordDetailFooterProps {
primaryAction?: ReactNode;
secondaryActions?: ReactNode;
children?: ReactNode;
className?: string;
}
export function RecordDetailFooter({
primaryAction,
secondaryActions,
children,
className,
}: RecordDetailFooterProps) {
return (
<div
className={cn(
"sticky bottom-0 z-(--app-sheet-z-sticky) mt-auto flex items-center justify-between gap-3 border-t border-border bg-card px-(--app-sheet-space-panel) py-3 sm:px-(--app-sheet-space-card)",
className,
)}
>
{children ? (
children
) : (
<>
<div className="flex-1">{primaryAction}</div>
{secondaryActions ? <div className="shrink-0">{secondaryActions}</div> : null}
</>
)}
</div>
);
}
/* -------------------------------------------------------------------------------------------------
* Compound export
* -----------------------------------------------------------------------------------------------*/
RecordDetailSheet.Header = RecordDetailHeader;
RecordDetailSheet.Body = RecordDetailBody;
RecordDetailSheet.Section = RecordDetailSection;
RecordDetailSheet.Card = RecordDetailCard;
RecordDetailSheet.Row = RecordDetailRow;
RecordDetailSheet.ActionTray = RecordDetailActionTray;
RecordDetailSheet.Footer = RecordDetailFooter;
export const AdaptiveRecordDetailSheet = RecordDetailSheet;
// src/app-sheet.tsx
import { useState, type ComponentProps } from "react";
import {
SheetContent,
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
export type AppSheetSize = "sm" | "md" | "lg";
/**
* How a side sheet presents itself below the desktop breakpoint.
*
* `bottom` keeps the desktop side panel and docks it to the bottom edge on
* touch. `fullscreen` gives the panel the entire touch viewport, which is what
* a record detail workflow needs: the surface stops reading as an overlay on
* top of a page and starts reading as its own screen.
*/
export type AppSheetMobilePresentation = "bottom" | "fullscreen";
export type AppSheetContentProps = ComponentProps<typeof SheetContent> & {
size?: AppSheetSize;
mobilePresentation?: AppSheetMobilePresentation;
};
export function AppSheetContent({
className,
size = "md",
mobilePresentation = "bottom",
...props
}: AppSheetContentProps) {
return (
<SheetContent
data-slot="app-sheet-content"
data-mobile-presentation={mobilePresentation}
data-size={size}
className={cn(
"w-full max-w-full gap-0 overflow-hidden border-border bg-card p-0 text-foreground data-[side=left]:w-full data-[side=left]:max-w-full data-[side=right]:w-full data-[side=right]:max-w-full",
size === "sm" &&
"sm:data-[side=left]:max-w-(--app-sheet-width-sm) sm:data-[side=right]:max-w-(--app-sheet-width-sm)",
size === "md" &&
"sm:data-[side=left]:max-w-(--app-sheet-width-md) sm:data-[side=right]:max-w-(--app-sheet-width-md)",
size === "lg" &&
"sm:data-[side=left]:max-w-(--app-sheet-width-lg) sm:data-[side=right]:max-w-(--app-sheet-width-lg)",
className,
)}
{...props}
/>
);
}
export function AppSheetHeader({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="app-sheet-header"
className={cn(
"sticky top-0 z-(--app-sheet-z-sticky) border-b border-border bg-card px-(--app-sheet-space-panel) py-(--app-sheet-space-inline-lg) sm:px-(--app-sheet-space-card)",
className,
)}
{...props}
/>
);
}
export function AppSheetTitle({
className,
...props
}: ComponentProps<typeof SheetTitle>) {
return (
<SheetTitle
data-slot="app-sheet-title"
className={cn("text-[1.0625rem] leading-[1.375rem] font-semibold tracking-[-0.006em] text-foreground", className)}
{...props}
/>
);
}
export function AppSheetDescription({
className,
...props
}: ComponentProps<typeof SheetDescription>) {
return (
<SheetDescription
data-slot="app-sheet-description"
className={cn("mt-1 text-sm leading-[1.375rem] text-muted-foreground", className)}
{...props}
/>
);
}
/**
* The body marks itself once it has been scrolled. The header divider only
* needs to exist when content is actually passing underneath it; at rest the
* header and the content read as one continuous surface.
*/
export function AppSheetBody({
className,
onScroll,
...props
}: ComponentProps<"div">) {
const [scrolled, setScrolled] = useState(false);
return (
<div
data-slot="app-sheet-body"
data-scrolled={scrolled ? "true" : undefined}
onScroll={(event) => {
setScrolled(event.currentTarget.scrollTop > 0);
onScroll?.(event);
}}
className={cn(
"min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-(--app-sheet-space-panel) py-(--app-sheet-space-panel) sm:px-(--app-sheet-space-card) sm:py-(--app-sheet-space-card)",
className,
)}
{...props}
/>
);
}
export function AppSheetFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="app-sheet-footer"
className={cn(
"sticky bottom-0 mt-auto border-t border-border bg-card px-(--app-sheet-space-panel) py-(--app-sheet-space-inline-lg) sm:px-(--app-sheet-space-card)",
className,
)}
{...props}
/>
);
}
// src/adaptive-record-detail-sheet.css
/*
* Layout tokens for the Adaptive Record Detail Sheet.
*
* Colour comes entirely from the Consumer's own shadcn theme (--card,
* --border, --foreground, --muted-foreground); nothing here bakes a palette.
* These tokens only carry the panel rhythm and the sheet widths, so the
* Artifact keeps its proportions in any theme.
*/
:root {
--app-sheet-space-inline-md: 0.75rem;
--app-sheet-space-inline-lg: 1rem;
--app-sheet-space-panel: 1.25rem;
--app-sheet-space-card: 1.5rem;
--app-sheet-width-sm: 26.25rem;
--app-sheet-width-md: 38.75rem;
--app-sheet-width-lg: 42.5rem;
--app-sheet-z-sticky: 20;
}
/*
* Desktop presentation: a side panel beside the record list.
* Touch presentation: the record owns the whole screen.
*
* Everything below this breakpoint is the Touch presentation. Driving it from
* the stylesheet rather than a width listener means the correct presentation is
* painted on the very first frame, with no flash and nothing to hydrate.
*/
@media (max-width: 47.999rem) {
[data-slot="app-sheet-content"][data-mobile-presentation="fullscreen"] {
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100dvh;
max-height: none;
border: 0;
border-radius: 0;
box-shadow: none;
}
/* Translucent chrome keeps the scrolled content readable underneath. */
[data-slot="app-sheet-content"][data-mobile-presentation="fullscreen"]
[data-slot="app-sheet-header"] {
background: color-mix(in srgb, var(--card) 94%, transparent);
backdrop-filter: blur(14px);
padding-top: max(1rem, env(safe-area-inset-top));
}
[data-slot="app-sheet-content"][data-mobile-presentation="fullscreen"]
[data-slot="app-sheet-footer"] {
background: color-mix(in srgb, var(--card) 94%, transparent);
backdrop-filter: blur(14px);
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
/* Touch targets get the full comfortable height on a handheld. */
[data-slot="app-sheet-content"][data-mobile-presentation="fullscreen"]
[data-slot="record-detail-section-label"] {
font-size: 0.8125rem;
text-transform: none;
letter-spacing: normal;
}
}
/*
* The header divider only earns its keep once content passes underneath it.
* At rest the header and the body read as one continuous surface.
*/
[data-slot="app-sheet-header"] {
border-bottom-color: transparent;
transition: border-bottom-color 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-slot="app-sheet-body"][data-scrolled="true"] {
border-top-color: var(--border);
}
[data-slot="app-sheet-content"]:has(
[data-slot="app-sheet-body"][data-scrolled="true"]
)
[data-slot="app-sheet-header"] {
border-bottom-color: var(--border);
}
@media (prefers-reduced-motion: reduce) {
[data-slot="app-sheet-header"] {
transition: none;
}
}