Skip to main content

DetailPageLayout

Implemented

Generic detail-page host organism (W3 organisms plan). Slot-host for the shared view-one-record chrome — back-trail (Breadcrumbs or TextLink), header (eyebrow / H1 + StatusBadge / meta / actions), section navigation (declarative sections mapped to the Tabs molecule via split rendering — nav-only strip + organism-rendered tabpanel — or a freeform navigation override e.g. Stepper), body with optional sticky 20rem aside (content-first DOM + CSS mobile order flip, EventTabs tickets-first parity), footer divider, overlays — plus four page states (ready/loading/error/notFound) as full swaps with whole-node fallback overrides. Zero i18n keys (English-literal defaults, ListPanel precedent). Adopted by client-portal tickets/[id] and storefront dashboard EventDetail (W3).

Categoryorganisms
Sourcesrc/components/organisms/DetailPageLayout/DetailPageLayout.tsx
AI HintUse for every detail screen (ticket/order/event/profile/stats-per-record). Prefer declarative sections (Tabs keyboard support for free); the navigation override is for Stepper-shaped step nav (?step= sync stays caller-side). Non-ready states: pass state + a whole-node fallback override — do not wrap the organism in your own loading branch. The aside renders ABOVE the body on mobile by design (EventTabs tickets-first parity). Supersedes EventDetail + EventDetailLayout (deprecated 2026-09-02).

Generic detail-page host organism (W3 organisms plan). Owns the shared "view one record" chrome — back-trail, header (eyebrow / H1 + status / meta / actions), section navigation, body with optional sticky aside, footer divider, overlays — plus the four page states (ready / loading / error / notFound) as full swaps with whole-node fallback overrides. Slot-host, not renderer: every region is ReactNode in, ReactNode out.

DetailPageLayout

Organism — Detail-page family (W3)

Canonical composition (portal ticket detail parity): breadcrumbs back-trail, Caption eyebrow, H1 + StatusBadge, Body meta, ButtonGroup actions, three declarative sections rendered through the Tabs molecule (keyboard support for free), and the footer with the organism-owned border-t divider.

Ticket tkt_01HZX8A1

Summer Beats Festival 2026

upcoming

Sat, Aug 16 · 7:30 PM · Parc Jean-Drapeau

Door
Parc Jean-Drapeau
Seat
General admission
Paid
$84.00 CAD
Holder
Alice Tremblay

This component supports light and dark mode.

Page states

Organism — state machine

Non-ready states replace everything inside the root landmark with the fallback node (default or override). The root keeps only the landmark + width chrome — the H1 aria-labelledby wiring is dropped while not ready because the fallback owns its own semantics (Spinner role=status, Alert role=alert, EmptyState role=status).

state="loading" — default fallback (Spinner, role=status)

Loading…

state="error" — default fallback (Alert variant=error, role=alert)

state="notFound" — custom notFoundFallback (whole-node override)

Ticket not found

We couldn't find a ticket with that ID.

Organism — freeform nav

The navigation slot replaces the declarative tab strip only (e.g. the dashboard Stepper). Body content still comes from sections (active panel) or children, rendered as a plain container — tabpanel semantics apply only when the declarative Tabs strip actually mounts. URL sync (?step=) stays caller-side.

← Events

OSM Symphonie n° 5

published

Info

The Stepper is a freeform navigation override — it replaces the declarative tab strip only. The body comes from children (or the active sections panel), and the ?step= URL sync stays caller-side, exactly like the dashboard event detail page.

Click a step — the layout chrome (back-trail, header, landmark wiring) is the organism's; only the nav strip and the panel content change.

Sticky aside

Organism — two-column body

The aside is a sticky 20rem right column at ≥lg. On mobile it renders ABOVE the body: content-first DOM with a CSS order flip (EventTabs tickets-first parity). Without an aside the content spans the full width.

← All events

Le Gymnase présente

Jazz sous les étoiles

Wed, Jul 30 · 8:00 PM · Place des Arts

14 concerts en plein air sur deux scènes. Programmation 2026 : Pat Metheny, Esperanza Spalding, BadBadNotGood et plusieurs artistes québécois dont Rémi Bolduc et Christine Jensen.

Chrome variants

Organism — root element, width, hero, overlays, footer

as="article" (public-page root landmark), width="lg" reading column, hero band above the back-trail (raw node — MediaHero molecule is a flagged follow-up), overlays rendered last outside the labelled flow (static placeholder — a real modal would portal), and the organism-owned footer divider.

Festival · 14e édition

← All events

Le Gymnase présente

Jazz sous les étoiles

Wed, Jul 30 · 8:00 PM · Place des Arts

The as="article" root landmark, width="lg" reading column, hero band above the back-trail, overlays rendered after the flow content, and the organism-owned footer divider — one composed example.

Overlay slot (static placeholder)

Overlays render last, outside the labelled flow — a real modal would portal here.

Content-source union

A ready render must carry a body — the TypeScript signature enforces it: sections (declarative tabs) or children (no-nav body, or the companion of a navigation override). Non-ready states are exempt: they are full-page swaps.

// TS-enforced (DetailPageContentSource):
{ state?: "ready" } & ({ sections: DetailPageSection[] } | { children: ReactNode })
| { state: "loading" | "error" | "notFound" }   // full swap, no body required

Usage

import { DetailPageLayout } from '@epasslive/design-system'

// Canonical (portal ticket detail): declarative sections, uncontrolled tabs
<DetailPageLayout
  breadcrumbs={{ items: [{ id: 'tickets', label: 'All tickets', href: '/tickets' }, { id: 'current', label: ticket.eventTitle }] }}
  eyebrow={<Caption as="p">Ticket {ticket.id}</Caption>}
  title={ticket.eventTitle}
  status={{ variant: 'info', label: ticket.status }}
  meta={<Body size="lg">{ticket.date.formatted} · {ticket.venue.name}</Body>}
  sections={[
    { key: 'overview', label: 'Overview', panel: <OverviewPanel /> },
    { key: 'transfer', label: 'Transfer', panel: <TransferPanel /> },
    { key: 'history', label: 'History', panel: <HistoryPanel /> },
  ]}
  footer={<TextLink href="/tickets" variant="muted">Back to all tickets</TextLink>}
/>

// Dashboard (Stepper nav + controlled ?step= sync stays caller-side)
<DetailPageLayout
  title={event.name}
  meta={<BackLinkAndStatusSubline />}
  actions={<SmButtonCluster />}
  navigation={<EventEditorNav active={activeTab} onChange={selectTab} />}
  overlays={<><ConfirmModal … /></>}
>
  <SettingsPanel … />
</DetailPageLayout>

// Not-found branch (full swap; fallback owns its own semantics)
<DetailPageLayout state="notFound" width="sm" title="Ticket"
  notFoundFallback={<EmptyState title="Ticket not found" action={<TextLink href="/tickets">Back to all tickets</TextLink>} />}
/>

i18n

The organism owns zero keys. English-literal defaults exist for the three state branches (Loading…, Something went wrong, Not found) and the declarative tablist aria-label (Sections) — each overridable by a whole-node prop or the navigation override. Consumers own every label they pass (storefront → next-intl; portal → hardcoded EN).

Migration

EventDetail and EventDetailLayout are deprecated in favour of this organism (see their docs pages). Adopted in W3: client-portal/src/app/tickets/[id]/page.tsx and storefront/src/components/dashboard/EventDetail.tsx.

Props

Prop
Type
Default
Description
title*
ReactNode
—

The single H1 — also the label source for the root landmark (aria-labelledby).

sections
DetailPageSection[]
—

Declarative tab sections mapped to the Tabs molecule (nav-only strip; the organism renders the active tabpanel).

children
ReactNode
—

No-nav body (statistics-style pages), or the panels companion of a navigation override.

state
"ready" | "loading" | "error" | "notFound"
"ready"

Page state. Non-ready states are full swaps — fallback only, no header.

width
"sm" | "md" | "lg" | "full"
"md"

Reading width (max-w-2xl / 4xl / 5xl / none).

Full prop reference (all 27 props, including the content-source union semantics) lives in DetailPageLayout.mdx.

Props

Preview

DetailPageLayout

Prop
Type
Required
Default
Description
id
string
Optional
useId()

Stable id base; the H1 receives id + '-title' for the root's aria-labelledby wiring.

breadcrumbs
{ items: BreadcrumbItem[]; ariaLabel?: string }
Optional
—

Back-trail via the Breadcrumbs molecule. Mutually exclusive with backLink; breadcrumbs wins when both are given.

backLink
{ href: string; label: ReactNode }
Optional
—

Back-trail via a TextLink. Ignored when breadcrumbs is present.

title
ReactNode
Required
—

The single H1 — also the label source for the root landmark.

eyebrow
ReactNode
Optional
null

Small line above the H1 (e.g. a Caption with the record id).

status
{ variant: StatusBadgeVariant; label: string }
Optional
—

StatusBadge rendered inline after the H1.

meta
ReactNode
Optional
null

Line(s) under the H1 (e.g. a Body size=lg subtitle, or a back-link + status sub-line).

actions
ReactNode
Optional
null

Right-aligned cluster on >=sm (e.g. a ButtonGroup); wraps below the titles on mobile.

sections
DetailPageSection[]
Optional
—

Declarative tab sections mapped to the Tabs molecule (nav-only strip + organism-rendered tabpanel). One of sections/children is required on ready renders.

activeSectionId
string
Optional
—

Controlled active tab id (declarative mode).

defaultActiveSectionId
string
Optional
first section key

Uncontrolled initial tab id (declarative mode).

onSectionChange
(id: string) => void
Optional
—

Called when a declarative tab is activated.

navigation
ReactNode
Optional
—

Freeform nav override (e.g. a Stepper) — replaces the declarative tab strip only; body still comes from sections/children.

children
ReactNode
Optional
—

No-nav body (statistics-style pages), or the panels companion of a navigation override. One of sections/children is required on ready renders.

hero
ReactNode
Optional
—

Full-bleed band above the back-trail (raw slot — MediaHero molecule is a flagged follow-up).

aside
ReactNode
Optional
—

Sticky 20rem right column at lg; renders above the body on mobile (content-first DOM + CSS order flip).

footer
ReactNode
Optional
—

Footer content — the organism renders the border-t divider chrome.

overlays
ReactNode
Optional
—

Modals/portals — rendered last, un-styled, outside the labelled flow.

state
'ready' | 'loading' | 'error' | 'notFound'
Optional
'ready'

Page state. Non-ready states are full swaps of everything inside the root (fallback only, no header/nav).

loadingFallback
ReactNode
Optional
Spinner + 'Loading…'

Whole-node override for the loading branch.

errorFallback
ReactNode
Optional
Alert variant='error'

Whole-node override for the error branch ('Something went wrong').

notFoundFallback
ReactNode
Optional
EmptyState

Whole-node override for the not-found branch ('Not found').

width
'sm' | 'md' | 'lg' | 'full'
Optional
'md'

Reading width: max-w-2xl / 4xl / 5xl / none (full = in-app shells).

as
'section' | 'article'
Optional
'section'

Root landmark element (public pages use article).

ariaLabel
string
Optional
—

Extra description on the root landmark.

className
string
Optional
—

Extra classes on the root (page padding stays caller-side).

data-ds
string
Optional
'DetailPageLayout'

Data-attribute hook for consumer CSS (see the dashboard .event-detail block).

Quick use:<DetailPageLayout />

Accessibility

ARIA roleregion

behavior

1 rule

DetailPageLayout organism spec §3 (W3)

non-ready states render the fallback only — full swap of everything inside the root, no header/nav

ARIA

2 rules

WCAG 1.3.1

root section/article labelled by the H1 (aria-labelledby id-title); the label is dropped on non-ready states

WAI-ARIA APG Tabs pattern

declarative sections keep the Tabs WAI-ARIA tabs pattern — nav strip + organism-rendered tabpanel with matching aria-controls/aria-labelledby

Usage

Import

tsxSnippet
import { DetailPageLayout } from "@epasslive/design-system"

Example 1

tsxSnippet
<DetailPageLayout title={ticket.eventTitle} status={{variant:'info',label:'upcoming'}} sections={[{key:'overview',label:'Overview',panel:<Overview/>}]} footer={<TextLink>Back</TextLink>} />

Example 2

tsxSnippet
<DetailPageLayout state='notFound' width='sm' title='Ticket' notFoundFallback={<EmptyState title='Ticket not found' />} />

Example 3

tsxSnippet
<DetailPageLayout title={event.name} navigation={<EventEditorNav active={tab} onChange={setTab} />}><SettingsPanel … /></DetailPageLayout>

Anti-patterns

— Do not hand-roll section/header/footer shells around an H1 — that chrome is this organism's job

— Do not re-wrap or duplicate the Tabs molecule's keyboard handling when using declarative sections

— Do not pass both sections and freeform children expecting both to render — the active section panel wins over children

— Do not use className to fight the width variants — pick width first (full for in-app shells)

Design System v4.1.0 | ePassLive