Block
Story band
A full-width band on the page surface, blue, yellow or ink. When it comes into view, the big headline and the text inside rise in turn.
- #arc
- #block
- #band
- #section
- #story
- #blog
- #animation
- #marketing
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/story-band.jsonFiles
components/arc/blocks/story-band/story-band.module.csscomponents/arc/blocks/story-band/story-band.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownstory-band.module.cssstory-band.module.cssstory-band.module.csscss
/* The band's colours, per tone. --band-bg is the face, --band-ink the prose and a headline's key words, --band-muted a headline's other
words (BigHeadline reads --band-ink and --band-muted), --band-link the links, --band-code-bg the fill behind code (a faint tint of the
ink, so the code text stays in --band-ink). lib/theme/contrast.test.ts (dsui) reads these blocks and checks every theme in light and
dark: 4.5:1 for --band-ink, --band-link and --band-ink on --band-code-bg, 3:1 for --band-muted (it only colours the big headline). */
.band {
--band-bg: var(--surface);
--band-ink: var(--foreground);
--band-muted: var(--text-secondary);
--band-link: var(--accent-strong);
--band-code-bg: color-mix(in oklab, var(--band-ink) 8%, transparent);
/* A hairline around code (decoration only, not text), so the box reads even where the fill is faint. */
--band-code-edge: color-mix(in oklab, var(--band-ink) 20%, transparent);
position: relative;
overflow: hidden;
padding-block: clamp(var(--space-20), 11vw, 168px);
background: var(--band-bg);
color: var(--band-ink);
}
.band[data-tone="blue"] {
--band-bg: var(--accent);
--band-ink: var(--accent-foreground);
--band-muted: color-mix(in oklab, var(--accent-foreground) 75%, transparent);
--band-link: var(--accent-foreground);
/* A tint of the ink would wash out the white text on the light-mode blue (4.51:1 at 8%); a little of --inverse deepens it instead.
10% keeps every theme at 4.96:1 or more (Christmas dark is the lowest: its accent-foreground is dark). */
--band-code-bg: color-mix(in oklab, var(--inverse) 10%, transparent);
}
.band[data-tone="yellow"] {
--band-bg: var(--highlight);
--band-ink: var(--highlight-foreground);
--band-muted: color-mix(in oklab, var(--highlight-foreground) 75%, transparent);
--band-link: var(--highlight-foreground);
}
.band[data-tone="ink"] {
--band-bg: var(--inverse);
--band-ink: var(--inverse-foreground);
--band-muted: color-mix(in oklab, var(--inverse-foreground) 75%, transparent);
--band-link: var(--inverse-foreground);
}
/* In dark mode --inverse sits barely above the page (1.25:1 on the dark --surface), so the band vanished. There the face is lifted
toward its own ink: 12% of --inverse-foreground gives about oklch(35.5%), 1.69:1 against the page in every theme, still a dark ink
field (the white prose stays above 10:1). Same guards as foundation.css: the site's switch, then the OS when nothing is chosen. */
:global(:root[data-theme="dark"]) .band[data-tone="ink"], :global(:root.dark) .band[data-tone="ink"] { --band-bg: color-mix(in oklab, var(--inverse) 88%, var(--inverse-foreground)); }
@media (prefers-color-scheme: dark) { :global(:root:not([data-theme="light"]):not([data-theme="dark"]):not(.light):not(.dark)) .band[data-tone="ink"] { --band-bg: color-mix(in oklab, var(--inverse) 88%, var(--inverse-foreground)); } }
/* The shared keyboard ring is the accent, which vanishes on a blue face: on a coloured band it follows the band's ink. */
.band:not([data-tone="plain"]) { --focus-outline: var(--band-ink); }
/* On a coloured face, buttons, pill links and chips (linked or not; Chip marks itself with data-arc-chip) drop their edge: a grey rim
around a white pill reads as noise on blue or yellow. Only the colour goes; the 1px width stays, so nothing shifts, and the fill shows
through (backgrounds paint under the border). The Arc Button and Chip have no border variable to set, so this is a descendant rule.
It uses :is, not :where, on purpose: its specificity (0,4,0) must beat their own hover and press border colours (up to 0,3,0), which
would otherwise bring the edge back. Prose links have no border, so it changes nothing for them. The keyboard ring is an outline. */
.band:not([data-tone="plain"]) .inner :is(a, button, [data-arc-chip]) { border-color: transparent; }
/* A linked chip's own hover only recolours its rim and text; with the rim gone it would barely answer, so on a coloured band it also
takes a tint of the band. The press keeps the chip's own fill (this rule would otherwise outrank it). Transitions are the chip's. */
@media (hover: hover) and (pointer: fine) {
.band:not([data-tone="plain"]) .inner [data-arc-chip="link"]:hover { background: color-mix(in oklab, var(--band-bg) 18%, var(--surface)); }
}
.band:not([data-tone="plain"]) .inner [data-arc-chip="link"]:active { background: var(--surface-muted); }
.band[data-tight="true"] { padding-block: clamp(var(--space-12), 7vw, 112px); }
.inner {
position: relative;
max-width: 1240px;
margin: 0 auto;
padding-inline: max(var(--space-4), env(safe-area-inset-left, 0px)) max(var(--space-4), env(safe-area-inset-right, 0px));
}
/* Markdown prose inside the band: the plain elements MDX renders as the band's own children. Only those (and what is inside them) are
styled, so a component placed in the band keeps its own look. A short measure and a loose line keep a lead readable on a colour. */
.inner > :where(p, ul, ol, blockquote) {
max-width: 44ch;
margin: clamp(var(--space-5), 3vw, 36px) 0 0;
color: var(--band-ink);
font-size: clamp(16px, 1.7vw, 21px);
font-weight: 500;
line-height: 1.65;
text-wrap: pretty;
word-break: keep-all;
}
/* Headings, tables, rules and images from Markdown. Tailwind preflight resets headings to the body size, so without these a `##`
would read smaller than the band's own text. `:not([class])` keeps BigHeadline's h2 (a classed component) out. */
.inner > :where(h2:not([class]), h3:not([class])) {
max-width: 30ch;
margin: clamp(var(--space-10), 5vw, 64px) 0 0;
color: var(--band-ink);
font-weight: 800;
line-height: 1.25;
letter-spacing: -.02em;
text-wrap: balance;
word-break: keep-all;
}
.inner > :where(h2:not([class])) { font-size: clamp(24px, 2.8vw, 36px); }
.inner > :where(h3:not([class])) { font-size: clamp(22px, 2.1vw, 27px); }
.inner > :where(h2:not([class]), h3:not([class])) + :where(p, ul, ol, table) { margin-top: var(--space-4); }
/* A table keeps its natural width and scrolls sideways on a narrow screen instead of pushing the page wider. */
.inner > :where(table) {
display: block;
width: max-content;
max-width: 100%;
margin: clamp(var(--space-5), 3vw, 36px) 0 0;
overflow-x: auto;
border-collapse: collapse;
color: var(--band-ink);
font-size: clamp(15px, 1.4vw, 18px);
line-height: 1.5;
font-variant-numeric: tabular-nums;
}
.inner > :where(table) :where(th, td) { padding: var(--space-2) var(--space-4); border-bottom: 1px solid var(--band-code-edge); vertical-align: top; }
.inner > :where(table) :where(th) { border-bottom-color: var(--band-muted); font-weight: 700; }
.inner > :where(table) :where(th, td):where(:first-child) { padding-inline-start: 0; }
.inner > :where(table, h2:not([class]), h3:not([class])) :where(a) { color: var(--band-link); text-decoration: underline; text-decoration-thickness: .08em; text-underline-offset: .2em; }
.inner > :where(table) :where(code) { padding: .1em .38em; border-radius: .4em; background: var(--band-code-bg); box-shadow: inset 0 0 0 1px var(--band-code-edge); font-size: .88em; }
.inner > :where(hr) {
max-width: 44ch;
height: 0;
margin: clamp(var(--space-8), 5vw, 64px) 0 0;
border: none;
border-top: 1px solid var(--band-code-edge);
}
/* An image is a child of its paragraph: it shrinks to the measure and takes the surface corner. */
.inner > :where(p) :where(img) { max-width: 100%; height: auto; border-radius: var(--radius-surface); }
.inner > :where(:first-child) { margin-top: 0; }
/* A page's base styles (Tailwind preflight) remove list markers; a band's lists always show them, in the band's colours. */
.inner > :where(ul, ol) { padding-inline-start: 1.5em; list-style-position: outside; }
.inner > :where(ul) { list-style-type: disc; }
.inner > :where(ol) { list-style-type: decimal; }
.inner > :where(ul, ol) > :where(li) { padding-inline-start: .25em; }
.inner > :where(ul, ol) > :where(li + li) { margin-top: var(--space-2); }
.inner > :where(ul) > :where(li)::marker { color: var(--band-muted); }
.inner > :where(ol) > :where(li)::marker { color: var(--band-ink); font-weight: 700; font-variant-numeric: tabular-nums; }
/* Nested lists (inside an li, any depth) get their own markers and indent too; the reset removes those as well. */
.inner > :where(ul, ol) :where(ul, ol) { margin-block: var(--space-2) 0; padding-inline-start: var(--space-6); list-style-position: outside; }
.inner > :where(ul, ol) :where(ul) { list-style-type: circle; }
.inner > :where(ul, ol) :where(ol) { list-style-type: lower-alpha; }
.inner > :where(ul, ol) :where(ul, ol) > :where(li + li) { margin-top: var(--space-1); }
.inner > :where(ul, ol) :where(ul) > :where(li)::marker { color: var(--band-muted); }
.inner > :where(ul, ol) :where(ol) > :where(li)::marker { color: var(--band-ink); font-weight: 700; }
.inner > :where(blockquote) { padding-inline-start: 1em; border-inline-start: 3px solid var(--band-muted); }
.inner > :where(blockquote) > :where(p) { margin: 0; }
.inner > :where(blockquote) > :where(p + p) { margin-top: var(--space-3); }
.inner > :where(p, ul, ol, blockquote) :where(strong) { font-weight: 800; }
.inner > :where(p, ul, ol, blockquote) :where(code) {
padding: .1em .38em;
border-radius: .4em;
background: var(--band-code-bg);
box-shadow: inset 0 0 0 1px var(--band-code-edge);
color: var(--band-ink);
font-size: .88em;
}
/* A fenced code block: a rounded box in the band's code fill that scrolls sideways instead of breaking long lines. */
.inner > :where(pre) {
max-width: min(100%, 72ch);
margin: clamp(var(--space-5), 3vw, 36px) 0 0;
padding: var(--space-4) var(--space-5);
overflow-x: auto;
border-radius: var(--radius-control);
background: var(--band-code-bg);
box-shadow: inset 0 0 0 1px var(--band-code-edge);
color: var(--band-ink);
font-size: clamp(14px, 1.3vw, 16px);
line-height: 1.6;
white-space: pre;
tab-size: 2;
}
.inner > :where(pre:first-child) { margin-top: 0; }
.inner > :where(pre) > :where(code) { padding: 0; border-radius: 0; background: none; box-shadow: none; color: inherit; font-size: inherit; }
.inner > :where(p, ul, ol, blockquote) :where(a) {
border-radius: .2em;
color: var(--band-link);
font-weight: 700;
text-decoration: underline;
text-decoration-thickness: .08em;
text-underline-offset: .2em;
transition: text-decoration-thickness var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);
}
.inner > :where(p, ul, ol, blockquote) :where(a:hover) { text-decoration-thickness: .16em; }
.inner > :where(p, ul, ol, blockquote) :where(a:active) { background: color-mix(in oklab, var(--band-link) 16%, transparent); }
/* The prose rises after the headline's words once the band is in view. */
.inner > :where(p, ul, ol, blockquote, pre) {
opacity: 0;
transform: translateY(var(--space-5));
transition: opacity var(--duration-considered) var(--ease-enter) var(--duration-moderate), transform calc(var(--duration-considered) * 1.5) var(--ease-enter) var(--duration-moderate);
}
.band[data-in="true"] .inner > :where(p, ul, ol, blockquote, pre) { opacity: 1; transform: none; }
/* Server rendered prose waits below. If scripts have not taken over after 2.4s, show it anyway; the class goes away on hydration. */
.pending .inner > :where(p, ul, ol, blockquote, pre) { animation: settle var(--duration-considered) var(--ease-enter) 2.4s forwards; }
@keyframes settle { to { opacity: 1; transform: none; } }
/* Without scripts nothing would ever lift the prose, so it starts in place. */
@media (scripting: none) {
.inner > :where(p, ul, ol, blockquote, pre) { opacity: 1; transform: none; transition: none; }
.pending .inner > :where(p, ul, ol, blockquote, pre) { animation: none; }
}
/* Reduced motion (the OS setting or the site's own) shows the prose from the first paint, before the hook can say so. */
@media (prefers-reduced-motion: reduce) {
.inner > :where(p, ul, ol, blockquote, pre) { opacity: 1; transform: none; transition: none; }
.pending .inner > :where(p, ul, ol, blockquote, pre) { animation: none; }
.inner > :where(p, ul, ol, blockquote) :where(a) { transition: none; }
}
:global(:root[data-motion="reduce"]) .inner > :where(p, ul, ol, blockquote, pre) { opacity: 1; transform: none; transition: none; }
:global(:root[data-motion="reduce"]) .pending .inner > :where(p, ul, ol, blockquote, pre) { animation: none; }
/* On paper there is no scrolling to bring a band into view: every line is printed in place. Browsers leave backgrounds out by
default, which would leave the light text of a blue or ink band on white paper, so the band asks for its face to be printed. */
@media print {
.band { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
.inner > :where(p, ul, ol, blockquote, pre) { opacity: 1; transform: none; transition: none; }
.pending .inner > :where(p, ul, ol, blockquote, pre) { animation: none; }
}
story-band.test.tsstory-band.test.tsstory-band.test.tsts
import { readFileSync } from "node:fs";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { useBandInView } from "../../lib/band-in-view";
import { Chip } from "../../chip/chip";
import { BigHeadline } from "../big-headline/big-headline";
import { STORY_BAND_IN_VIEW, StoryBand } from "./story-band";
/** Prints what the surrounding band says about being in view, so the test can see the provider. */
function Probe() {
return createElement("output", null, String(useBandInView()));
}
describe("StoryBand server render", () => {
const render = (props: Partial<Parameters<typeof StoryBand>[0]> = {}, ...children: Parameters<typeof createElement>[2][]) =>
renderToStaticMarkup(createElement(StoryBand, props as Parameters<typeof StoryBand>[0], ...(children.length ? children : [createElement("p", null, "본문이에요.")])));
it("is a section with the children inside, plain by default and named by its label", () => {
const html = render({ label: "우리가 만드는 것" });
expect(html).toMatch(/^<section[\s>]/);
expect(html).toContain('data-tone="plain"');
expect(html).toContain('aria-label="우리가 만드는 것"');
expect(html).toContain("<p>본문이에요.</p>");
expect(render()).not.toContain("aria-label");
});
it("marks the tone and the tight spacing", () => {
for (const tone of ["plain", "blue", "yellow", "ink"] as const) expect(render({ tone })).toContain(`data-tone="${tone}"`);
expect(render({ tight: true })).toContain('data-tight="true"');
expect(render()).not.toContain("data-tight");
});
it("starts out of view on the server and tells the blocks inside through the band context", () => {
expect(render()).toContain('data-in="false"');
expect(render({}, createElement(Probe))).toContain("<output>false</output>");
// A headline inside follows the band instead of watching the viewport on its own.
expect(render({}, createElement(BigHeadline, { lines: "제목" }))).toMatch(/<h2[^>]*data-in="false"/);
});
it("keeps the prose readable without scripts and with reduced motion, and uses tokens only", () => {
const css = readFileSync(new URL("./story-band.module.css", import.meta.url), "utf8");
expect(css).toMatch(/@media \(scripting: none\)/);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
expect(css).toMatch(/:root\[data-motion="reduce"\]/);
expect(css).not.toMatch(/#[0-9a-f]{3,6}\b/i);
expect(css).not.toMatch(/rgba?\(/);
});
});
describe("StoryBand in-view options", () => {
it("can be met by a band of any height: any visible part counts, not a fraction of the band", () => {
// A fraction like 0.2 needs a fifth of the band on screen at once, which a band taller than five viewports never reaches.
expect(STORY_BAND_IN_VIEW.amount).toBe("some");
expect(STORY_BAND_IN_VIEW.once).toBe(true);
// The margin may only shrink the viewport by a fraction, so a tall band still crosses it.
expect(STORY_BAND_IN_VIEW.margin).toMatch(/^0px 0px -\d{1,2}% 0px$/);
});
});
describe("StoryBand prose stylesheet", () => {
const css = readFileSync(new URL("./story-band.module.css", import.meta.url), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
const rule = (selector: string) => {
const at = css.indexOf(`${selector} {`);
expect(at, `missing rule ${selector}`).toBeGreaterThan(-1);
return css.slice(at, css.indexOf("}", at));
};
it("brings list markers back (page base styles remove them) and colours them from the band", () => {
expect(rule(".inner > :where(ul)")).toMatch(/list-style-type:\s*disc/);
expect(rule(".inner > :where(ol)")).toMatch(/list-style-type:\s*decimal/);
expect(rule(".inner > :where(ul) > :where(li)::marker")).toMatch(/color:\s*var\(--band-(ink|muted)\)/);
expect(rule(".inner > :where(ol) > :where(li)::marker")).toMatch(/color:\s*var\(--band-(ink|muted)\)/);
});
it("gives nested lists (inside an li, at any depth) their own markers and indent, coloured from the band", () => {
const nested = rule(".inner > :where(ul, ol) :where(ul, ol)");
expect(nested).toMatch(/padding-inline-start:\s*var\(--space-/);
expect(rule(".inner > :where(ul, ol) :where(ul)")).toMatch(/list-style-type:\s*circle/);
expect(rule(".inner > :where(ul, ol) :where(ol)")).toMatch(/list-style-type:\s*lower-alpha/);
expect(rule(".inner > :where(ul, ol) :where(ul) > :where(li)::marker")).toMatch(/color:\s*var\(--band-(ink|muted)\)/);
expect(rule(".inner > :where(ul, ol) :where(ol) > :where(li)::marker")).toMatch(/color:\s*var\(--band-(ink|muted)\)/);
});
it("boxes code blocks and inline code in the band's code fill, and lets long lines scroll", () => {
const pre = rule(".inner > :where(pre)");
expect(pre).toMatch(/background:\s*var\(--band-code-bg\)/);
expect(pre).toMatch(/overflow-x:\s*auto/);
expect(pre).toMatch(/border-radius:\s*var\(--radius-/);
expect(pre).toMatch(/padding:\s*var\(--space-/);
expect(rule(".inner > :where(p, ul, ol, blockquote) :where(code)")).toMatch(/background:\s*var\(--band-code-bg\)/);
// A code block rises and has the same no-script and reduced-motion fallbacks as the prose.
expect(css).toContain('.band[data-in="true"] .inner > :where(p, ul, ol, blockquote, pre)');
expect(css.match(/:where\(p, ul, ol, blockquote, pre\) \{ opacity: 1; transform: none; transition: none; \}/g)?.length).toBe(4);
});
it("styles headings, tables, rules and images from Markdown in the band's colours, and leaves BigHeadline's h2 alone", () => {
const heading = rule(".inner > :where(h2:not([class]), h3:not([class]))");
expect(heading).toMatch(/color:\s*var\(--band-ink\)/);
expect(heading).toMatch(/font-weight:\s*\d00/);
expect(heading).toMatch(/margin:\s*clamp\(var\(--space-/);
// Larger than the band's prose (21px at most), h2 larger than h3.
const max = (selector: string) => Number(/font-size:\s*clamp\(\d+px,\s*[\d.]+vw,\s*(\d+)px\)/.exec(rule(selector))?.[1]);
expect(max(".inner > :where(h2:not([class]))")).toBeGreaterThan(max(".inner > :where(h3:not([class]))"));
expect(max(".inner > :where(h3:not([class]))")).toBeGreaterThan(21);
const table = rule(".inner > :where(table)");
expect(table).toMatch(/overflow-x:\s*auto/);
expect(table).toMatch(/max-width:\s*100%/);
expect(table).toMatch(/color:\s*var\(--band-ink\)/);
expect(rule(".inner > :where(table) :where(th, td)")).toMatch(/border-bottom:\s*1px solid var\(--band-/);
expect(rule(".inner > :where(table) :where(th)")).toMatch(/font-weight:\s*\d00/);
expect(rule(".inner > :where(hr)")).toMatch(/border-top:\s*1px solid var\(--band-/);
const img = rule(".inner > :where(p) :where(img)");
expect(img).toMatch(/max-width:\s*100%/);
expect(img).toMatch(/border-radius:\s*var\(--radius-/);
});
});
describe("StoryBand in print", () => {
const css = readFileSync(new URL("./story-band.module.css", import.meta.url), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
const print = /@media print \{([\s\S]*?)\n\}/.exec(css)?.[1] ?? "";
it("shows every line of prose on paper, whether or not the band was scrolled past", () => {
expect(print).toContain(".inner > :where(p, ul, ol, blockquote, pre) { opacity: 1; transform: none; transition: none; }");
expect(print).toContain(".pending .inner > :where(p, ul, ol, blockquote, pre) { animation: none; }");
});
it("prints the band's face so light text on blue or ink is not left on white paper", () => {
expect(print).toMatch(/\.band \{[^}]*print-color-adjust:\s*exact/);
});
});
describe("StoryBand edges on coloured tones", () => {
const css = readFileSync(new URL("./story-band.module.css", import.meta.url), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
const blocks = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({ selector: m[1].trim(), body: m[2] }));
const transparentEdges = blocks.filter((b) => /border-color\s*:\s*transparent/.test(b.body)).map((b) => b.selector);
it("takes the border colour off buttons, pill links and chips (linked or not) inside a coloured band, keeping the 1px width", () => {
expect(transparentEdges).toEqual(['.band:not([data-tone="plain"]) .inner :is(a, button, [data-arc-chip])']);
// Only the colour goes: no `border: 0` or `border-width` change, so nothing shifts.
expect(css).not.toMatch(/border(-width)?\s*:\s*0/);
});
it("keeps the edges on the plain tone: the rule that clears them excludes exactly the plain tone", () => {
for (const selector of transparentEdges) {
expect(selector.startsWith('.band:not([data-tone="plain"]) ')).toBe(true);
expect(selector).not.toMatch(/\.band\[data-tone="plain"\]/);
}
});
it("gives a linked chip a visible hover fill on a coloured band, and keeps the press fill", () => {
const hover = blocks.find((b) => b.selector === '.band:not([data-tone="plain"]) .inner [data-arc-chip="link"]:hover');
expect(hover?.body).toMatch(/background:\s*color-mix\(in oklab, var\(--band-bg\) \d+%, var\(--surface\)\)/);
const press = blocks.find((b) => b.selector === '.band:not([data-tone="plain"]) .inner [data-arc-chip="link"]:active');
expect(press?.body).toMatch(/background:\s*var\(--surface-muted\)/);
// The hover only applies to a fine pointer, like the chip's own hover.
expect(css).toMatch(/@media \(hover: hover\) and \(pointer: fine\) \{\s*\.band:not\(\[data-tone="plain"\]\) \.inner \[data-arc-chip="link"\]:hover/);
});
it("can reach chips inside the band: Chip marks itself as a link or a label", () => {
const html = renderToStaticMarkup(
createElement(StoryBand, { tone: "blue" } as Parameters<typeof StoryBand>[0], createElement(Chip, { children: "라벨" }), createElement(Chip, { href: "/t", children: "링크" })),
);
expect(html).toMatch(/<span[^>]*data-arc-chip="label"[^>]*>라벨/);
expect(html).toMatch(/<a[^>]*data-arc-chip="link"[^>]*>[\s\S]*?링크/);
});
});
describe("StoryBand ink face in dark mode", () => {
const css = readFileSync(new URL("./story-band.module.css", import.meta.url), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
const LIFT = "--band-bg: color-mix(in oklab, var(--inverse) 88%, var(--inverse-foreground));";
// The same guards as foundation.css: the site's switch (data-theme="dark" or .dark) and the OS setting when nothing is chosen.
const FORCED = ':global(:root[data-theme="dark"]) .band[data-tone="ink"], :global(:root.dark) .band[data-tone="ink"] {';
const OS = '@media (prefers-color-scheme: dark) { :global(:root:not([data-theme="light"]):not([data-theme="dark"]):not(.light):not(.dark)) .band[data-tone="ink"] {';
const body = (head: string) => {
const at = css.indexOf(head);
expect(at, `missing ${head}`).toBeGreaterThan(-1);
return css.slice(at + head.length, css.indexOf("}", at)).trim();
};
it("lifts the ink face toward its own ink, both when the site is switched to dark and when it follows a dark OS", () => {
expect(body(FORCED)).toBe(LIFT);
expect(body(OS)).toBe(LIFT);
// Only these two rules carry a dark-only colour, and only for the ink tone.
expect(css.match(/:global\(:root[^)]*\)[^{]*\.band\[data-tone="\w+"\]/g)).toEqual([
':global(:root[data-theme="dark"]) .band[data-tone="ink"], :global(:root.dark) .band[data-tone="ink"]',
':global(:root:not([data-theme="light"]):not([data-theme="dark"]):not(.light):not(.dark)) .band[data-tone="ink"]',
]);
});
it("keeps the light ink face and the other tones as they were", () => {
const tone = (name: string) => body(`\n.band[data-tone="${name}"] {`);
expect(tone("ink")).toMatch(/^--band-bg: var\(--inverse\);/);
expect(tone("blue")).toMatch(/^--band-bg: var\(--accent\);/);
expect(tone("yellow")).toMatch(/^--band-bg: var\(--highlight\);/);
expect(body("\n.band {")).toMatch(/^--band-bg: var\(--surface\);/);
});
});
story-band.tsxstory-band.tsxstory-band.tsxtsx
"use client";
import { useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { useInView } from "motion/react";
import { BandInViewContext } from "../../lib/band-in-view";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import styles from "./story-band.module.css";
export type StoryBandTone = "plain" | "blue" | "yellow" | "ink";
export interface StoryBandProps {
/** The band's face: the page surface (`plain`), the accent (`blue`), the highlight (`yellow`) or the inverse (`ink`). */
tone?: StoryBandTone;
/** Names the section for assistive technology. */
label?: string;
/** Less space above and below, for a band that follows another closely. */
tight?: boolean;
children: ReactNode;
}
/** Matches the delay of the stylesheet fallback: scripts that arrive later than this keep the already shown prose still. */
const FALLBACK_MS = 2400;
const lateBoot = typeof window !== "undefined" && performance.now() > FALLBACK_MS;
const subscribeHydration = () => () => {};
/**
* When the band counts as in view. Any visible pixel counts (`amount: "some"`): a fraction such as 0.2 can never be met by a band
* taller than five viewports, which would leave its prose hidden. The bottom margin waits until the band's top is a fifth of the
* way up the screen, so the rise is still seen.
*/
export const STORY_BAND_IN_VIEW = { once: true, amount: "some", margin: "0px 0px -20% 0px" } as const;
/**
* A full-width coloured band for a story. When it scrolls into view it tells the blocks inside (a `BigHeadline` reads it through
* `BandInViewContext`) and its own prose rises after the headline. `data-in` on the section lets other children key off the same cue.
* The prose is in the server markup; without scripts, with reduced motion, or when scripts are slow, it is simply shown.
*/
export function StoryBand({ tone = "plain", label, tight = false, children }: StoryBandProps) {
const ref = useRef<HTMLElement>(null);
const seen = useInView(ref, STORY_BAND_IN_VIEW);
const reduced = useReducedMotion();
const hydrated = useSyncExternalStore(subscribeHydration, () => true, () => false);
const [serverRendered] = useState(!hydrated);
const late = hydrated && serverRendered && lateBoot;
const shown = seen || reduced || late;
return (
<section
ref={ref}
className={`${styles.band} ${hydrated ? "" : styles.pending}`}
data-tone={tone}
data-tight={tight ? "true" : undefined}
data-in={shown ? "true" : "false"}
aria-label={label || undefined}
>
<BandInViewContext.Provider value={shown}>
<div className={styles.inner}>{children}</div>
</BandInViewContext.Provider>
</section>
);
}
export default StoryBand;
Used by
- Post heroBlock
The opening band of a post. It gathers the way back, tag pills, a big heavy title, the summary, the dates and the translation and Markdown links, with an optional illustration beside them.