Component librarylocal catalog
Back to catalog

Problem ledger

A sticky editorial intro beside a numbered, hairline-ruled list whose rows indent on hover.

sectioneditoriallist

Open preview in a new tab

Use this component

Three ways to get Problem ledger into one of your own projects, all pre-filled with this component's name and library page.

Start here

Component reference

Paste it into a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving the branding and content open.

Read it first
“Problem ledger” from my Astro Component Library:
https://astro.baysixmedia.com/components/problem-ledger/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

ChatGPT brief

Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.

Read it first
Help me add this component to one of my Astro websites:

Component: Problem ledger
Library page: https://astro.baysixmedia.com/components/problem-ledger

I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]

Place it:
[ENTER LOCATION OR SELECTOR]

Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]

Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.

Placement prompt

Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.

Read it first
Add Problem ledger from my Astro Component Library to this Astro project.

Library page:
https://astro.baysixmedia.com/components/problem-ledger

First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

src/components/library/ProblemLedger.astro

one fileNo imports, no companion files, no packages. Copy ProblemLedger.astro into any Astro project's components folder and render it.

---
/**
 * ProblemLedger — a sticky editorial intro beside a numbered, hairline-ruled
 * list of points.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages:
 * drop it into any Astro project and render it.
 *
 * The shape is an editorial two-column split. The intro column sticks while
 * the list scrolls past it, so the framing stays on screen for the whole
 * read. Below the desktop breakpoint the split collapses and the intro simply
 * sits above the list.
 *
 * Measured behaviour:
 *   split         46/54 at >=1024, single column below
 *   column gap    64px at >=1024, 48px stacked
 *   row rhythm    32px vertical padding, 1px hairline between rows
 *   numerals      share the heading's fluid size, so they scale together
 *   hover indent  0 -> 8px over 700ms, fine pointers at >=768 only
 *   reveal        900ms mask-up per row, 60ms stagger between title and detail
 *   easing        cubic-bezier(0.16, 1, 0.3, 1) throughout
 *
 * Several instances can share a page: the reveal script scopes every query to
 * the instance root and observes that instance's rows only.
 */

export interface LedgerItem {
	/** Used for the row's element id. Falls back to no id. */
	id?: string;
	/** The point itself, set in the row's display size. */
	title: string;
	/** One line of elaboration under the title. Optional. */
	detail?: string;
}

interface Props {
	/** Small tracked label above the heading. Empty string hides it. */
	eyebrow?: string;
	/** Section heading. Empty string hides it. */
	heading?: string;
	/** Paragraph under the heading. Empty string hides it. */
	intro?: string;
	/** The numbered rows. Six reproduce the tested rhythm; any count works. */
	items?: LedgerItem[];
	/** First number in the sequence, for a list that continues another. */
	numberFrom?: number;
	/** Stick the intro column while the list scrolls. Desktop only either way. */
	sticky?: boolean;
	/** Offset from the top of the viewport when stuck. */
	stickyTop?: string;
	/** grid-template-columns for the desktop split. */
	split?: string;
	/** Section background. */
	surface?: string;
	/** Body and heading colour. Muted tones are derived from it. */
	ink?: string;
	/** How far a row slides right on hover. "0px" turns the indent off. */
	hoverIndent?: string;
	/** Animate rows in as they enter the viewport. */
	reveal?: boolean;
	/** Heading level, for pages where h2 is wrong. */
	headingLevel?: "h1" | "h2" | "h3";
	class?: string;
	id?: string;
}

const defaultItems: LedgerItem[] = [
	{
		title: "Components get copied, not shared",
		detail:
			"A pattern is pasted into a new screen and quietly forks. Two months on there are four of it.",
	},
	{
		title: "Tokens live in three places at once",
		detail:
			"The design file, the stylesheet and a utility module each hold a different grey.",
	},
	{
		title: "Nobody owns the edges",
		detail:
			"The happy path gets designed. Empty, loading and error states get invented at build time.",
	},
	{
		title: "Accessibility arrives as a bug report",
		detail:
			"Contrast and focus order are checked after release rather than while the thing is drawn.",
	},
	{
		title: "The docs describe last year's version",
		detail:
			"Usage notes drift from the code until reading them costs more than reading the source.",
	},
	{
		title: "Every new surface restarts the argument",
		detail:
			"With no written rule, each team decides spacing, radius and tone from scratch again.",
	},
];

const {
	eyebrow = "01 — Friction",
	heading = "Speed is rarely the problem. Agreement is.",
	intro = "A shared library only pays off when everyone reaches for the same part. These are the six ways that stops happening.",
	items = defaultItems,
	numberFrom = 1,
	sticky = true,
	stickyTop = "96px",
	split = "46fr 54fr",
	surface = "#f7f6f3",
	ink = "#14161a",
	hoverIndent = "8px",
	reveal = true,
	headingLevel = "h2",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;
const label = (i: number) => String(i + numberFrom).padStart(2, "0");
---

<section
	class:list={["ledger", className]}
	id={id}
	data-ledger
	data-reveal={reveal ? "" : undefined}
	data-sticky={sticky ? "" : undefined}
	style={`--pl-surface:${surface};--pl-ink:${ink};--pl-sticky-top:${stickyTop};--pl-split:${split};--pl-indent:${hoverIndent};`}
>
	<div class="shell">
		<div class="intro" data-group>
			{
				eyebrow && (
					<p class="kicker">
						<span class="kicker-text">{eyebrow}</span>
						<span class="kicker-rule" data-r-rule />
					</p>
				)
			}
			{
				heading && (
					<Heading class="heading" data-r-mask style="--d:0ms">
						<span>{heading}</span>
					</Heading>
				)
			}
			{
				intro && (
					<p class="intro-text" data-r-up style="--d:80ms">
						{intro}
					</p>
				)
			}
		</div>

		<ol class="rows">
			{
				items.map((item, i) => (
					<li class="row" id={item.id} data-group>
						<div class="row-inner">
							<span class="num" aria-hidden="true">
								{label(i)}
							</span>
							<div class="row-body">
								<h3 class="row-title" data-r-mask style="--d:0ms">
									<span>{item.title}</span>
								</h3>
								{item.detail && (
									<p class="row-detail" data-r-up style="--d:60ms">
										{item.detail}
									</p>
								)}
							</div>
						</div>
					</li>
				))
			}
		</ol>
	</div>
</section>

<style>
	.ledger {
		/* Every tone is derived from --pl-ink, so one override re-tints the
		   whole section instead of leaving half of it behind. */
		--pl-muted: color-mix(in srgb, var(--pl-ink) 52%, transparent);
		--pl-detail: color-mix(in srgb, var(--pl-ink) 68%, transparent);
		--pl-hairline: color-mix(in srgb, var(--pl-ink) 13%, transparent);

		--pl-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		/* Fluid scale: 22px at a 390px viewport, 40px from 1280px up. */
		--pl-display: clamp(1.375rem, 1.05rem + 1.6vw, 2.5rem);
		--pl-title: clamp(1.125rem, 1.02rem + 0.5vw, 1.5rem);
		--pl-body: 1rem;

		--pl-gutter: clamp(20px, 5vw, 80px);
		--pl-maxw: 1280px;
		--pl-ease: cubic-bezier(0.16, 1, 0.3, 1);

		box-sizing: border-box;
		display: block;
		position: relative;
		padding: clamp(64px, 9vw, 128px) var(--pl-gutter);
		background: var(--pl-surface);
		color: var(--pl-ink);
		font-family: var(--pl-font);
	}

	.ledger :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.shell {
		max-width: var(--pl-maxw);
		margin-inline: auto;
		display: grid;
		grid-template-columns: 1fr;
		gap: 48px;
	}

	/* ---------- intro column ---------- */

	.intro {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
		margin: 0;
	}

	.kicker {
		display: flex;
		align-items: center;
		gap: 16px;
		width: 100%;
		margin: 0;
		font-size: 0.7rem;
		font-weight: 600;
		letter-spacing: 0.28em;
		text-transform: uppercase;
		color: var(--pl-muted);
	}

	.kicker-text {
		flex-shrink: 0;
	}

	.kicker-rule {
		flex: 1;
		height: 1px;
		background: var(--pl-hairline);
		transform-origin: left center;
	}

	.heading {
		margin: 0;
		font-size: var(--pl-display);
		line-height: 1.35;
		font-weight: 650;
		letter-spacing: -0.02em;
		text-wrap: balance;
	}

	.intro-text {
		margin: 0;
		max-width: 46ch;
		font-size: var(--pl-body);
		line-height: 1.6;
		color: var(--pl-detail);
	}

	/* ---------- list ---------- */

	.rows {
		list-style: none;
		margin: 0;
		padding: 0;
		border-top: 1px solid var(--pl-hairline);
	}

	.row {
		border-bottom: 1px solid var(--pl-hairline);
	}

	.row-inner {
		display: flex;
		gap: 16px;
		padding-block: 32px;
		padding-left: 0;
		transition: padding-left 700ms var(--pl-ease);
	}

	.num {
		flex-shrink: 0;
		font-size: var(--pl-display);
		line-height: 1;
		padding-top: 0.15em;
		font-weight: 500;
		font-variant-numeric: tabular-nums;
		letter-spacing: 0.02em;
		color: var(--pl-muted);
	}

	.row-body {
		display: flex;
		flex-direction: column;
		gap: 8px;
		min-width: 0;
	}

	.row-title {
		margin: 0;
		font-size: var(--pl-title);
		line-height: 1.45;
		font-weight: 650;
		letter-spacing: -0.01em;
	}

	.row-detail {
		margin: 0;
		font-size: var(--pl-body);
		line-height: 1.6;
		color: var(--pl-detail);
		text-wrap: pretty;
	}

	/* ---------- reveal ----------
	 * Hidden in CSS rather than armed by script, so the first paint is already
	 * correct and nothing flashes visible then snaps away. The reduced-motion
	 * and <noscript> escapes below cover the cases where it must not apply.
	 */

	.ledger[data-reveal] [data-r-mask] {
		overflow: hidden;
		/* Give descenders the room the clip would otherwise cut. */
		padding-bottom: 0.12em;
		margin-bottom: -0.12em;
	}

	.ledger[data-reveal] [data-r-mask] > span {
		display: block;
		transform: translateY(110%);
		opacity: 0;
		transition:
			transform 900ms var(--pl-ease) var(--d, 0ms),
			opacity 900ms var(--pl-ease) var(--d, 0ms);
	}

	.ledger[data-reveal] [data-r-up] {
		transform: translateY(28px);
		opacity: 0;
		transition:
			transform 900ms var(--pl-ease) var(--d, 0ms),
			opacity 900ms var(--pl-ease) var(--d, 0ms);
	}

	.ledger[data-reveal] [data-r-rule] {
		transform: scaleX(0);
		transition: transform 1100ms var(--pl-ease);
	}

	.ledger[data-reveal] [data-in] :is([data-r-mask] > span, [data-r-up]) {
		transform: none;
		opacity: 1;
	}

	.ledger[data-reveal] [data-in] [data-r-rule] {
		transform: scaleX(1);
	}

	/* ---------- breakpoints ---------- */

	@media (min-width: 768px) {
		.row-inner {
			gap: 32px;
		}
	}

	@media (min-width: 1024px) {
		.shell {
			grid-template-columns: var(--pl-split);
			gap: 64px;
		}

		.ledger[data-sticky] .intro {
			position: sticky;
			top: var(--pl-sticky-top);
			align-self: start;
		}
	}

	/* The indent is a pointer affordance. On a touch screen :hover sticks
	   after the finger lifts, which leaves a row indented for no reason. */
	@media (min-width: 768px) and (hover: hover) and (pointer: fine) {
		.row:hover .row-inner {
			padding-left: var(--pl-indent);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.ledger[data-reveal] :is([data-r-mask] > span, [data-r-up]) {
			transform: none;
			opacity: 1;
			transition: none;
		}

		.ledger[data-reveal] [data-r-rule] {
			transform: scaleX(1);
			transition: none;
		}

		.row-inner {
			transition: none;
		}
	}
</style>

<noscript>
	<style>
		.ledger[data-reveal] :is([data-r-mask] > span, [data-r-up]) {
			transform: none;
			opacity: 1;
		}

		.ledger[data-reveal] [data-r-rule] {
			transform: scaleX(1);
		}
	</style>
</noscript>

<script>
	/*
	 * One observer per instance. Every query is scoped to the instance root,
	 * so two ledgers on a page reveal independently and never see each
	 * other's rows.
	 */
	const setup = (root: HTMLElement) => {
		if (!root.hasAttribute("data-reveal")) return;

		const groups = [...root.querySelectorAll<HTMLElement>("[data-group]")];
		if (groups.length === 0) return;

		/* Without IntersectionObserver there is no way to know when a row
		   arrives. Show everything rather than leave the section blank. */
		if (!("IntersectionObserver" in window)) {
			groups.forEach((group) => group.setAttribute("data-in", ""));
			return;
		}

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					entry.target.setAttribute("data-in", "");
					/* Reveal is one-way: a row that has arrived stays arrived. */
					observer.unobserve(entry.target);
				}
			},
			{ rootMargin: "0px 0px -8% 0px", threshold: 0 },
		);

		groups.forEach((group) => observer.observe(group));
	};

	document.querySelectorAll<HTMLElement>("[data-ledger]").forEach(setup);
</script>