@k2b/ui
DetailPanel
Quiet contextual inspectors with compact identity, flat sections, and one scroll owner.
Full-width destinations keep navigation primary while a sibling overflow menu or one direct secondary action holds further commands. Disabled rows preserve unavailable resource context.
TSX
Copy<DetailPanel.Section title="Related resources" icon="ti ti-link"> <DetailPanel.Action href="/app/mail/inbox?conversation=Conv01" leading={<i class="ti ti-mail" aria-hidden="true" />} title="Release planning" description="Mail conversation" menuLabel="More actions for Release planning" menuItems={[{ label: "Unlink", icon: "ti ti-unlink", action: unlink }]} /> <DetailPanel.Action onClick={openBudget} leading={<i class="ti ti-file-spreadsheet" aria-hidden="true" />} title="budget.xlsx" description="Spreadsheet · Uploaded today" secondaryAction={{ icon: "ti ti-trash", label: "Delete budget.xlsx", variant: "danger", onClick: confirmDelete }} /> <DetailPanel.Action type="button" disabled leading={<i class="ti ti-file-off" aria-hidden="true" />} title="Archived brief" description="Resource unavailable or no longer accessible" /></DetailPanel.Section>Two production-shaped inspectors demonstrate the final contract: padded identity headers, one stable scroll gutter absorbed by the trailing host inset, one flat frame for every summary, group, lone section and comment thread, and the same heading, icon and count columns in all of them.
Bug report
No preview · 5 minutes ago
Welcome!
Collaborative note
Contents
- H1
- H2
- H2
- H2
- H2
- H2
- H2
Comments
1- MK
Mara Klein Reply to Mara KleinThe introduction is ready for review.
Online
Info
- Created
- Jul 7, 2026
- Updated
- Jul 7, 2026
Versions
TSX
Copy<DetailPanel.Group label="Customer context"> <DetailPanel.Section title="Company" icon="ti ti-building" tone="accent"> <DescriptionList layout="rows" size="sm" items={companyItems} /> </DetailPanel.Section> <DetailPanel.Section title="Contact" icon="ti ti-user" tone="warning"> <DetailPanel.Action href="/contacts/ada" target="_blank" rel="noopener noreferrer" title="Ada Lovelace" menuLabel="More actions for Ada Lovelace" menuItems={[{ label: "Related requests", href: "/requests?contact=ada" }]} /> </DetailPanel.Section> <DetailPanel.Section title="Recent threads" icon="ti ti-history" tone="success">…</DetailPanel.Section></DetailPanel.Group> <DetailPanel.Section title="Subscription plan details" icon="ti ti-alert-triangle" tone="danger" actions={retry} /> <Discussion label="Comments" icon="ti ti-messages" count={1}> <Discussion.List>…</Discussion.List> <Discussion.Composer label="Add comment" placeholder="Add a comment" submitLabel="Post comment" onSubmit={postComment} /></Discussion>DetailPanel gives contextual inspectors one quiet content structure without
owning their surrounding drawer, workspace region, dialog, or domain state.
DetailPanel.Body automatically fades its top and bottom edges while content
remains beyond that edge. Header and footer controls stay outside the fade.
Set scrollFade={false} to disable it. The hint follows resize and live content
changes, reserves no space, and is disabled in forced-color mode.
Import
import { DescriptionList, DetailPanel, NumberInput } from "@k2b/ui";Use DetailPanel
Place it inside AppWorkspace.Detail for persistent selection context, or in
another host when the same inspector content is reused. The host owns opening,
closing, sizing, and persistence. An AppWorkspace.Detail column shares the
workspace surface; a waiting Detail opens as a raised drawer. Its sections and
groups have no boxes of their own. The application owns data, permissions,
mutations, and which sections are present. See
Detail and panel.
DetailPanel.Header keeps identity and actions in one compact region. Pass
icon for the standard accent-tinted identity tile or leading for an avatar
or another custom identity; they are mutually exclusive. Pass actions for
compact utilities such as more and close, and primaryActions for the small
set of prominent commands below the identity row. On touch devices, primary
actions keep 0.625rem from the identity row and between wrapped lines, so
their tap areas stay clear of a
meta action or the line above.
The title wraps to at most two lines, with the full string in its native hint.
The subtitle stays on one line and ends with an ellipsis; meta sits at its
trailing edge instead of competing with the title. The subtitle keeps at least
6rem, so a meta too wide for that moves below it, still on the trailing edge,
and wraps inside the header instead of hiding the subtitle; plain
text in meta uses the subtitle's size and color, while badges and buttons keep
their own.
DetailPanel.Body is the single scrolling element and accepts a
scrollPreserveKey. Its stable scrollbar gutter prevents content from shifting
when expanding content first makes the panel overflow. Inside
AppWorkspace.Detail, that gutter occupies the host's existing trailing inset
instead of adding a second gap. On a device whose primary pointer is coarse,
which usually draws overlay scrollbars that reserve no gutter, the panel keeps
the host inset instead, so its content has the same margin on both sides. The
pointer only stands in for the scrollbar style: a touch device with classic
scrollbars shows the gutter next to the inset, and a desktop with overlay
scrollbars keeps the panel at the trailing edge. Do not add a second
full-height scroller inside it. The panel keeps a 0.5rem horizontal gutter;
the AppWorkspace.Detail column adds its own 0.5rem inset.
Use DetailPanel.Summary once, directly below the header, when the selected
item has a primary set of facts or controls. In other hosts, the summary keeps
its own surface. Inside AppWorkspace.Detail, it shares the panel surface
and aligns with the header title. Do not repeat the summary for every group.
The panel uses --k2b-detail-panel-accent for restrained identity and action
accents, with the portable UI accent as its fallback. Text actions in section
and summary headings fall back to the theme's action color instead, so they
keep their contrast in dark mode. A host may map that hook to its own theme
token; DetailPanel does not know how the host derives it.
DetailPanel.Section draws no frame of its own. It groups content through
spacing and a sentence-case title, not a card, divider, or decorative
background; the group or the body frames it. Pass icon for a fixed section icon slot and tone to distinguish
portable accent, neutral, success, warning, or danger roles through
text color only. Pass description for short supporting context, count for
a numeric count chip, and meta for short state text such as “2 of 4 done”.
Both sit immediately after the title. A zero count reads “None yet”,
localized through the inherited UI locale. A stream such as activity has no
count. Give a normal section at most one trailing control through actions.
Only the description is muted: a field in actions keeps its own description and error styles. A normal section may omit
its body to represent a compact, actionable empty group. Set collapsible for
secondary content. Closed sections show a compact action row; open sections show
a heading with a collapse button on the right. Both states share one geometry
with the other section headings: the icon, title, meta, and chevron keep their
columns and the row its height when the section opens or closes. Hover colors
the title and chevron, and its fill bleeds 0.5rem into the panel gutter.
Keyboard focus adds the
shared focus ring, drawn inside the row where the panel would clip it. Their
content stays mounted when
closed, preserving unsaved input and child state. Collapsible sections reserve
the header action for collapsing.
Use defaultOpen for an initially expanded section, or control it with open
and onOpenChange. Controlled state is authoritative: the callback requests a
change but does not open or close the section itself. This lets an editor open
the relevant section after validation fails. disabled disables both toggle
buttons; it does not disable the child controls. Use a fieldset for that.
The content owns its layout: wrap form controls in a column with a gap.
Use DetailPanel.Group when one or more sections form one stable context, such
as a company and its contacts or a document and its derived metadata. Merge
adjacent sections when they belong to that same context. Outside
AppWorkspace.Detail, the group owns the same quiet surface as the summary. Inside it, the group is transparent. Spacing and section headings
separate its sections without borders or lines. Pass label when the
shared context benefits from an accessible group name.
A section placed directly in DetailPanel.Body is a group of one. It follows
the group’s surface and inset in its host, so
its icon, title, count, and content start in the same columns as every grouped
section. A Discussion inside the panel does the
same. Do not wrap a standalone section in a group only to frame it, and do not
add an application frame, border, or padding around a section. Only a notice,
a progress bar, a short row of commands, or a feed whose items are each one
frame stands in the body without a section frame. Loose text belongs in the
header subtitle or a section, and a block with its own heading becomes a
section. Give every section of a panel an icon so the
titles share one column, and use size="xs" for text buttons in its
actions, so their label text ends at the same trailing edge as row metadata.
Sections accept arbitrary content. Use DescriptionList layout="rows" for
compact properties. When people edit those properties in place, make them
property rows: a control with
appearance="plain" as the row's value shows the value as text, and the whole
row opens its picker. Keep the summary's pencil action for the full edit form.
Use normal shared inputs for a full form inspector, and the
appropriate shared list, table, notice, preview, or editor for specialized
content. Use Discussion directly in the body when
notes or comments need their own labelled composer and author timeline. It
takes the frame and heading of a section, so it needs no group or wrapper. Rows
that are not DetailPanel.Action, such as a static address or an empty
message, start at the content edge without their own horizontal padding. A
left-aligned Placeholder in a section does the same on its own. Do not
add domain variants such as record, mail, or workflow to DetailPanel.
Use DetailPanel.Action for a full-width destination or command such as a
related record, attachment, or in-panel jump. Pass href for native link
semantics; omit it for a native button. leading, title, optional
description, and trailing keep row geometry and interaction states
consistent. Action rows align their optional leading icon with the section
icon column and keep their titles at the section title's text edge, even
without leading. All other content of a section with an icon inside a
DetailPanel starts at that same text edge: comment authors, static text,
progress, and row titles. A section used outside a panel, such as a form
section in an editor dialog, keeps its full width. Do not
add a second indent around action rows. Hover fill extends 0.5rem into the
panel gutter without moving the row. current marks the current destination
in the panel accent and sets aria-current (location for links, true for
buttons). wrap allows the title to use two lines; otherwise it ends with an
ellipsis. When a destination has secondary actions, pass declarative
menuItems together with the required accessible menuLabel. The primary link
and the Dots trigger render as sibling controls, so the whole main row remains
a native destination without nesting a button inside the link. With a mouse or
trackpad, including a tablet with a mouse, the Dots trigger stays hidden until
the row is hovered, keyboard-focused, or its menu is open. Touch-only screens
always show it. Do not use it for static key-value data, comments, history, or
form fields. Stacked action rows sit flush, so on
touch devices each row is at least 44 px (2.75rem) tall, including its
secondary and Dots buttons, and its tap area keeps the row height instead of
reaching into the neighbouring row. This follows the device, not the input in
use: a laptop with a touch screen gets the taller rows with a mouse too, and
devices without a touch screen keep the compact rows.
When one secondary command is frequent enough to deserve a direct control, such
as deleting an attachment, pass secondaryAction with an icon, an accessible
label, and onClick. Name the label after the row, such as
Delete budget.xlsx, so repeated buttons stay distinguishable. It renders as a
sibling icon button at the end of the row, before an optional Dots trigger, and
appears on row hover or keyboard focus with any pointer that can hover.
Touch-only screens always show it. Set variant: "danger" for a destructive
command; hover and focus then use the danger text color. The button only reports the
click. The application confirms destructive work, performs it, and refreshes
the list. When that removes the focused row, the application moves focus to
the row that takes its place. Keep further commands in menuItems.
API reference
type DetailPanelProps = {
children: JSX.Element; class?: string;
};
type DetailPanelHeaderBaseProps = {
title: JSX.Element; subtitle?: JSX.Element; meta?: JSX.Element; actions?: JSX.Element;
primaryActions?: JSX.Element; class?: string;
};
type DetailPanelHeaderProps = DetailPanelHeaderBaseProps &
(
| {
leading?: JSX.Element;
icon?: never;
}
| {
leading?: never;
icon?: string;
}
);
type DetailPanelBodyProps = {
children: JSX.Element; scrollPreserveKey?: string; scrollFade?: boolean; class?: string;
};
type DetailPanelSummaryProps = {
title: JSX.Element; children: JSX.Element; actions?: JSX.Element; class?: string;
};
type DetailPanelGroupProps = {
children: JSX.Element; label?: string; class?: string;
};
type DetailPanelTone = "accent" | "neutral" | "success" | "warning" | "danger";
type DetailPanelSectionBaseProps = {
title: JSX.Element; icon?: string; tone?: DetailPanelTone; description?: JSX.Element; count?: number; meta?: JSX.Element;
class?: string;
};
type DetailPanelSectionProps = DetailPanelSectionBaseProps &
(
| {
children?: JSX.Element;
actions?: JSX.Element;
collapsible?: false;
defaultOpen?: never;
open?: never;
onOpenChange?: never;
disabled?: never;
}
| {
children: JSX.Element;
actions?: never;
collapsible: true;
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
}
);
type DetailPanelActionSecondary = {
icon: string; label: string; onClick: () => void; variant?: "danger";
};
type DetailPanelActionBaseProps = {
title: JSX.Element; description?: JSX.Element; leading?: JSX.Element; trailing?: JSX.Element;
secondaryAction?: DetailPanelActionSecondary; current?: boolean; wrap?: boolean; class?: string;
};
type DetailPanelActionMenuProps =
| {
menuItems?: never;
menuLabel?: never;
}
| {
menuItems: readonly DropdownItem[];
menuLabel: string;
};
type DetailPanelActionLinkProps = DetailPanelActionBaseProps &
DetailPanelActionMenuProps &
Omit<ButtonLinkProps, "children" | "class" | "size" | "title" | "variant"> & {
href: string;
};
type DetailPanelActionButtonProps = DetailPanelActionBaseProps &
DetailPanelActionMenuProps &
Omit<ButtonProps, "children" | "class" | "size" | "title" | "variant"> & {
href?: never;
};
type DetailPanelActionProps = DetailPanelActionLinkProps | DetailPanelActionButtonProps;The *BaseProps shapes explain shared fields of the public unions. Header chooses icon/title or custom leading content through its union; Action chooses a menu, link, or native button. DropdownItem uses the menu contract; native button/link attributes retain their usual types. Boolean features are off unless enabled; Body owns the scroll area by default.
Accessibility
The header title is an h2; normal section titles are labelled h3 headings.
Decorative header and section icons are hidden from assistive technology, so
their adjacent text remains the label and color is never the only signal.
Collapsible sections use native buttons with expanded state and a relationship
to their content. Focus follows the toggle when its counterpart becomes visible.
Every icon-only action and every control embedded in a description value still
needs its own accessible name. DetailPanel.Action keeps its visible title as
the accessible name and renders a real link or button. Its secondary action is a
separate button in the tab order, named by label.
Runtime
The composition and its initial open state are server-renderable. Toggling a collapsible section requires browser code. Interactive children keep their own state and remain mounted when the section closes.
Example
<AppWorkspace.Detail id="item" open={selectedId() !== null} onClose={closeDetails} width="md">
<DetailPanel>
<DetailPanel.Header
icon="ti ti-building-warehouse"
title="Studio shelf"
subtitle="Locations · version 2"
primaryActions={<Toolbar label="Location actions"><Button size="xs">Edit</Button></Toolbar>}
actions={<IconButton label="Close details" onClick={closeDetails}><i class="ti ti-x" aria-hidden="true" /></IconButton>}
/>
<DetailPanel.Body scrollPreserveKey="location-detail">
<DetailPanel.Summary title="Overview">
<DescriptionList
layout="rows"
size="sm"
actionVisibility="progressive"
items={[
{ term: "Room", description: "Studio" },
{
term: "Quantity",
description: (
<NumberInput aria-label="Quantity" appearance="plain" min={0} value={quantity} onValueCommit={saveQuantity} />
),
},
]}
/>
</DetailPanel.Summary>
<DetailPanel.Section
title="Notes"
icon="ti ti-notes"
count={0}
description="Keep decisions with this location"
actions={<Button variant="ghost" size="xs">Add note</Button>}
/>
<DetailPanel.Group label="Inventory context">
<DetailPanel.Section title="Related records" icon="ti ti-link" tone="accent">
<DetailPanel.Action
href="/app/grids/locations/records/st-02"
leading={<i class="ti ti-building-warehouse" aria-hidden="true" />}
title="Studio"
description="Room · ST-02"
trailing={<i class="ti ti-chevron-right" aria-hidden="true" />}
/>
</DetailPanel.Section>
<DetailPanel.Section title="Attachments" icon="ti ti-paperclip" tone="neutral" count={2} />
</DetailPanel.Group>
<DetailPanel.Section title="History" icon="ti ti-history" collapsible>
…
</DetailPanel.Section>
</DetailPanel.Body>
</DetailPanel>
</AppWorkspace.Detail>