Layout
Section
A titled block with a description and actions, stacked or split beside its content as on a settings page.
- Built on
<section>aria-labelledbyContainer queries- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 1.5 kB
- Dependencies
- React
API keys
2 active, 1 expiring soon
Try its options
Loading its options.
Code
import { Button } from "@/components/equip/button";
import { Section } from "@/components/equip/section";
const row = {
height: 22,
borderRadius: "var(--eq-radius-s)",
background: "var(--eq-tint-strong)",
};
export default function Demo() {
return (
<Section
title="API keys"
description="2 active, 1 expiring soon"
actions={
<Button size="s" variant="outline">
New key
</Button>
}
style={{ width: 208 }}
>
<div style={row} />
</Section>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<section style="width:208px" class="eq-section" data-layout="stacked">
<div class="eq-section-layout">
<div class="eq-section-header">
<div class="eq-section-heading">
<h2 class="eq-section-title">API keys</h2>
<p class="eq-section-description">2 active, 1 expiring soon</p>
</div>
<div class="eq-section-actions">
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">New key</span>
</button>
</div>
</div>
<div class="eq-section-body">
<div style="height:22px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong)"></div>
</div>
</div>
</section>Examples
A settings page
split puts each title beside its fields once the section is 40rem wide and stacks them below that; an id names each section so it becomes a region.
Profile
Shown to people in your workspace.
Delete workspace
Removes every project, member and invoice. This cannot be undone.
Code
import { Button } from "@/components/equip/button";
import { Section } from "@/components/equip/section";
const label = { display: "grid", gap: 4, fontSize: "var(--eq-text-s)", fontWeight: "var(--eq-weight-strong)" } as const;
const input = {
height: 34,
padding: "0 12px",
border: "1px solid var(--eq-line-strong)",
borderRadius: "var(--eq-radius-m)",
background: "var(--eq-raised)",
color: "var(--eq-ink)",
font: "inherit",
fontWeight: "var(--eq-weight)",
} as const;
export default function Example() {
return (
<div style={{ display: "grid", gap: 24, width: "100%" }}>
<Section id="settings-profile" layout="split" title="Profile" description="Shown to people in your workspace.">
<div style={{ display: "grid", gap: 12, maxWidth: 360 }}>
<label style={label}>
Display name
<input style={input} defaultValue="Mira Holt" />
</label>
<label style={label}>
Role
<input style={input} defaultValue="Finance lead" />
</label>
</div>
</Section>
<Section
id="settings-danger"
layout="split"
divider
title="Delete workspace"
description="Removes every project, member and invoice. This cannot be undone."
>
<Button variant="danger">Delete Larder</Button>
</Section>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:24px;width:100%">
<section aria-labelledby="settings-profile-title" aria-describedby="settings-profile-description" id="settings-profile" class="eq-section" data-layout="split">
<div class="eq-section-layout">
<div class="eq-section-header">
<div class="eq-section-heading">
<h2 id="settings-profile-title" class="eq-section-title">Profile</h2>
<p id="settings-profile-description" class="eq-section-description">Shown to people in your workspace.</p>
</div>
</div>
<div class="eq-section-body">
<div style="display:grid;gap:12px;max-width:360px">
<label style="display:grid;gap:4px;font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">Display name<input style="height:34px;padding:0 12px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised);color:var(--eq-ink);font:inherit;font-weight:var(--eq-weight)" value="Mira Holt"></label>
<label style="display:grid;gap:4px;font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">Role<input style="height:34px;padding:0 12px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised);color:var(--eq-ink);font:inherit;font-weight:var(--eq-weight)" value="Finance lead"></label>
</div>
</div>
</div>
</section>
<section aria-labelledby="settings-danger-title" aria-describedby="settings-danger-description" id="settings-danger" class="eq-section" data-layout="split" data-divider="true">
<div class="eq-section-layout">
<div class="eq-section-header">
<div class="eq-section-heading">
<h2 id="settings-danger-title" class="eq-section-title">Delete workspace</h2>
<p id="settings-danger-description" class="eq-section-description">Removes every project, member and invoice. This cannot be undone.</p>
</div>
</div>
<div class="eq-section-body">
<button type="button" class="eq-button" data-variant="danger" data-size="m">
<span class="eq-button-label">Delete Larder</span>
</button>
</div>
</div>
</section>
</div>A dashboard panel
The actions wrap under the title when the section is narrow, so neither is squeezed.
Traffic
Last 24 hours across all regions
- Requests
- 4.2M
- Error rate
- 0.12%
- p95 latency
- 184 ms
Code
import { Button } from "@/components/equip/button";
import { Section } from "@/components/equip/section";
const stats = [
["Requests", "4.2M"],
["Error rate", "0.12%"],
["p95 latency", "184 ms"],
];
export default function Example() {
return (
<Section
id="traffic"
level={3}
title="Traffic"
description="Last 24 hours across all regions"
actions={
<>
<Button size="s" variant="ghost">
View logs
</Button>
<Button size="s" variant="outline">
Export
</Button>
</>
}
style={{ width: "100%", maxWidth: 560 }}
>
<dl style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(7rem, 1fr))", gap: 12, margin: 0 }}>
{stats.map(([term, value]) => (
<div key={term} style={{ padding: 12, borderRadius: "var(--eq-radius-m)", background: "var(--eq-tint)" }}>
<dt style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>{term}</dt>
<dd style={{ margin: 0, fontSize: "var(--eq-text-l)", fontWeight: "var(--eq-weight-strong)" }}>{value}</dd>
</div>
))}
</dl>
</Section>
);
}HTMLworks in any framework, with the part's CSS
<section aria-labelledby="traffic-title" aria-describedby="traffic-description" style="width:100%;max-width:560px" id="traffic" class="eq-section" data-layout="stacked">
<div class="eq-section-layout">
<div class="eq-section-header">
<div class="eq-section-heading">
<h3 id="traffic-title" class="eq-section-title">Traffic</h3>
<p id="traffic-description" class="eq-section-description">Last 24 hours across all regions</p>
</div>
<div class="eq-section-actions">
<button type="button" class="eq-button" data-variant="ghost" data-size="s">
<span class="eq-button-label">View logs</span>
</button>
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">Export</span>
</button>
</div>
</div>
<div class="eq-section-body">
<dl style="display:grid;grid-template-columns:repeat(auto-fit, minmax(7rem, 1fr));gap:12px;margin:0">
<div style="padding:12px;border-radius:var(--eq-radius-m);background:var(--eq-tint)">
<dt style="font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Requests</dt>
<dd style="margin:0;font-size:var(--eq-text-l);font-weight:var(--eq-weight-strong)">4.2M</dd>
</div>
<div style="padding:12px;border-radius:var(--eq-radius-m);background:var(--eq-tint)">
<dt style="font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Error rate</dt>
<dd style="margin:0;font-size:var(--eq-text-l);font-weight:var(--eq-weight-strong)">0.12%</dd>
</div>
<div style="padding:12px;border-radius:var(--eq-radius-m);background:var(--eq-tint)">
<dt style="font-size:var(--eq-text-xs);color:var(--eq-ink-2)">p95 latency</dt>
<dd style="margin:0;font-size:var(--eq-text-l);font-weight:var(--eq-weight-strong)">184 ms</dd>
</div>
</dl>
</div>
</div>
</section>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title | ReactNode | none | The heading text. |
description | ReactNode | none | One or two sentences under the title. |
actions | ReactNode | none | Buttons or links at the end of the header. |
level | 2 | 3 | 4 | 2 | The heading element. Match it to the outline of the page. |
layout | "stacked" | "split" | "stacked" | split puts the header in a column beside the content once the section is 40rem wide. |
divider | boolean | false | A hairline and space above, to separate it from the section before. |
id | string | none | Gives the heading the id "<id>-title" and labels the section with it, which makes the section a region landmark. |
...rest | ComponentProps<"section"> | none | Every native section attribute. |
Anatomy
| root | .eq-section | The section and the size container for its layout. |
|---|---|---|
| layout | .eq-section-layout | The grid that stacks or splits. |
| header | .eq-section-header | |
| heading | .eq-section-heading | Holds the title and description. |
| title | .eq-section-title | |
| description | .eq-section-description | |
| actions | .eq-section-actions | |
| body | .eq-section-body |
Notes
- Renders on the server with no client JavaScript.
- With an id the section is named by its title and described by its description, so it is announced as a region with that name.
- Without an id it is an unnamed section, which assistive tech does not treat as a landmark; use that for small blocks so the landmark list stays short.
- The split layout answers to the section's own width, not the viewport, so it also stacks inside a narrow column.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/section.tsx69 lines
import type { ComponentProps, ReactNode } from "react";
import "./section.css";
export type SectionProps = Omit<ComponentProps<"section">, "title"> & {
title: ReactNode;
/** One or two sentences under the title. */
description?: ReactNode;
/** Buttons or links at the end of the header. */
actions?: ReactNode;
/** The heading element. Match it to the outline of the page. */
level?: 2 | 3 | 4;
/**
* stacked puts the header above the content. split puts it in a column
* beside the content, as on a settings page, and stacks when narrow.
*/
layout?: "stacked" | "split";
/** A hairline above the section, to separate it from the one before. */
divider?: boolean;
};
/**
* A titled block of content. Pass an id and the section is named by its
* title, which makes it a region landmark.
*/
export function Section({
title,
description,
actions,
level = 2,
layout = "stacked",
divider = false,
id,
className,
children,
...rest
}: SectionProps) {
const Heading = `h${level}` as "h2";
const titleId = id ? `${id}-title` : undefined;
const descriptionId = id && description != null ? `${id}-description` : undefined;
return (
<section
aria-labelledby={titleId}
aria-describedby={descriptionId}
{...rest}
id={id}
className={["eq-section", className].filter(Boolean).join(" ")}
data-layout={layout}
data-divider={divider || undefined}
>
<div className="eq-section-layout">
<div className="eq-section-header">
<div className="eq-section-heading">
<Heading id={titleId} className="eq-section-title">
{title}
</Heading>
{description != null && (
<p id={descriptionId} className="eq-section-description">
{description}
</p>
)}
</div>
{actions != null && <div className="eq-section-actions">{actions}</div>}
</div>
<div className="eq-section-body">{children}</div>
</div>
</section>
);
}components/equip/section.css82 lines
@layer equip {
.eq-section {
box-sizing: border-box;
display: block;
min-width: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
/* The section is the container; the layout inside answers to its width. */
container: eq-section / inline-size;
}
.eq-section[data-divider] {
padding-top: var(--eq-space-5);
border-top: 1px solid var(--eq-line);
}
.eq-section-layout {
display: grid;
gap: var(--eq-space-4);
}
.eq-section-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--eq-space-2) var(--eq-space-4);
}
.eq-section-heading {
flex: 1 1 14rem;
min-width: 0;
}
.eq-section-title {
margin: 0;
font-size: var(--eq-text-l);
font-weight: var(--eq-weight-strong);
line-height: 1.3;
letter-spacing: -0.01em;
text-wrap: balance;
}
.eq-section-description {
max-width: 60ch;
margin: var(--eq-space-1) 0 0;
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
text-wrap: pretty;
}
.eq-section-actions {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--eq-space-2);
}
.eq-section-body {
min-width: 0;
}
/* Split: the header becomes a column beside the content once there is room for both. */
@container eq-section (width >= 40rem) {
.eq-section[data-layout="split"] > .eq-section-layout {
grid-template-columns: minmax(12rem, 1fr) minmax(0, 2fr);
gap: var(--eq-space-6);
}
.eq-section[data-layout="split"] > .eq-section-layout > .eq-section-header {
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
gap: var(--eq-space-3);
}
.eq-section[data-layout="split"] > .eq-section-layout > .eq-section-header > .eq-section-heading {
flex: none;
}
}
}