Retro Blog
A list-view shell for a personal blog or archive page. Masthead row, optional intro block, a vertical list of post links (date / title / excerpt), and optional sidebar / footer / decorative overlay slots. Pure layout — every chrome region is an optional ReactNode and the only data the shell consumes is a posts[] array of { id, title, date, excerpt? }. Generalised from the original retro shell into something that fits a devlog, a changelog, a small-internet zine, or an editorial archive.
Field notes on UI, monospace typography, and the slow Internet.
Scanlines and CRT veils — building the retro look
How a 1px striped overlay and a soft cursor glow restore the warmth of a 90s monitor.
Monospace rhythm in long-form writing
Tabular numerals, balanced headlines, and the case for fixed-width body copy.
The archive as a timeline
Grouping posts by month gives navigation rhythm without surrendering chronology.
Notes on the small internet
Installation
npx shadcn@latest add https://craftbits.dev/r/retro-blog.jsonUsage
Pass an array of post objects and let the shell render the list:
import { RetroBlog } from "@craft-bits/core";
const posts = [
{ id: "scanlines", title: "Scanlines and CRT veils", date: "2026-05-22" },
{ id: "mono", title: "Monospace rhythm", date: "2026-05-08" },
];
<RetroBlog posts={posts} />;Wire it into your router by passing a renderPostHref resolver:
<RetroBlog
posts={posts}
renderPostHref={(post) => `/posts/${post.id}`}
/>;Drop content into the optional header, intro, sidebar, footer, and decor slots without forking the shell:
<RetroBlog
posts={posts}
header={<MyMasthead />}
intro={<ArchiveBlurb />}
sidebar={<AboutCard />}
footer={<SiteFooter />}
decor={<ScanlineOverlay />}
renderPostHref={(post) => `/posts/${post.id}`}
/>;Understanding the component
- Pure slots, no controllers. The shell takes ReactNode props and a
posts[]array and arranges them. No data fetching, no router coupling, no scroll listeners. The original retro shell mountedRetroHeader,RetroTimeline,CraftSection,RetroSidebar,RetroFooter, andRetroDecordirectly; here all of that is the consumer's call via slot props. - Two-column body with a drawer fallback. The body row splits into a flex-1 main column and an optional sidebar at the
lg:breakpoint. Belowlg:the sidebar drops beneath the list and stretches full-width, matching the original cookbook drawer behaviour. - Decor sits in an
aria-hiddenoverlay. Thedecorslot is wrapped in an absolutely-positioned,pointer-events: nonecontainer above the page background but behind the content. Screen readers skip it; clicks pass through to the list. - Post rows are pure anchors. Each post is rendered through
RetroBlogPostLink— a single anchor with a stacked date / title / excerpt layout. Hover and:focus-visibleswap the row background to--cb-bg-mutedand bump the title to--cb-accentfor a strong scan signal. - Tabular numerals on the date. Dates render as
<time>withfont-variant-numeric: tabular-numsso vertical columns line up when the list is dense. - Three date formats.
dateFormatacceptslong(default — e.g. May 30, 2026),short(ISO date 2026-05-30), oriso(the raw ISO string). All three are deterministic in UTC so SSR and the client never diverge. - Two density presets.
cozy(default) leaves vertical space for a one-line excerpt and reads as an editorial archive;compacttightens row padding and gap to a dense changelog feel. - Data-attributes for nested styling. The root carries
data-cb-retro-blog,data-surface,data-density, anddata-sidebar; every region tags itself withdata-cb-retro-blog-regionso a single global rule can re-skin every instance.
Props
RetroBlog
| Prop | Type | Default | Description |
|---|---|---|---|
posts | ReadonlyArray<RetroBlogPost> | — | The post list rendered in the central column. Each entry needs id, title, and date; excerpt is optional. |
header | ReactNode | — | Masthead row pinned above the body. Wordmark + nav, banner, search bar. |
intro | ReactNode | — | Optional intro block rendered above the list — section heading + blurb. |
sidebar | ReactNode | — | Optional aside beside the list at lg:. Drops below the list on narrow viewports. |
footer | ReactNode | — | Optional footer row pinned beneath the body. |
decor | ReactNode | — | Decorative overlay (scanlines, CRT veil, cursor glow). Wrapped in aria-hidden, pointer-events: none. |
surface | 'plain' | 'muted' | 'plain' | Page background — --cb-bg or --cb-bg-muted. |
density | 'cozy' | 'compact' | 'cozy' | Vertical rhythm between list items. |
dateFormat | 'long' | 'short' | 'iso' | 'long' | Date label style. All three are UTC-deterministic for SSR safety. |
renderPostHref | (post: RetroBlogPost) => string | hash fallback | Resolves the link href for each post. Defaults to a hash fragment. |
listAriaLabel | string | 'Posts' | aria-label for the post list region. |
className | string | — | Merged onto the rendered root via cn(). |
...rest | Omit<HTMLAttributes<HTMLDivElement>, 'title'> | — | Any other div attribute. |
RetroBlogPost
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Stable unique id used for React keys and the default href fallback. |
title | string | yes | Display title shown as the link label. |
date | string | number | yes | Publication date — parseable string or epoch milliseconds. |
excerpt | string | no | Optional one-line summary rendered under the title. |
Accessibility
- The root renders a div, the main column is wrapped in a
<main>, and the sidebar is wrapped in an<aside>— so assistive tech announces the right landmarks without the consumer having to re-wrap. - The post list is an ordered list (
<ol>) witharia-label="Posts"(override vialistAriaLabel). Each item is an<li>so screen readers announce position-in-set. - Every post link has visible
:focus-visiblestyling — a ring in--cb-accentplus a background lift to--cb-bg-muted. - The date is wrapped in
<time>with adateTimeattribute set to the ISO representation so AT and search engines read the structured value. - The
decorslot is wrapped inaria-hidden="true"withpointer-events: noneso decorative overlays never interrupt the reading order or block clicks. - No motion — the shell never animates layout. Hover and focus transitions stay under 200ms and are colour-only, so reduced-motion preferences are respected implicitly.
- Color contrast: every default surface paints
--cb-bg/--cb-bg-mutedwith--cb-fg,--cb-fg-muted, and--cb-fg-subtletext. All three pass WCAG AA against both surfaces.
Credits
- Extracted from:
terminal-dreams(src/components/retro/RetroBlog.tsx). The original was a fixed composition of project-specific parts (RetroHeader,RetroTimeline,CraftSection,RetroSidebar,RetroFooter,RetroDecor) all bound to aTimelinePostshape with series grouping, month bucketing, Next.js routing, and auseRetroEffects/usePrefersReducedMotionmount step baked in. craft-bits keeps the masthead + list + sidebar + footer reading flow but reshapes the API into a fully slotted layout: every chrome region is an optional ReactNode, the list reads a minimumposts[]={id,title,date,excerpt?}shape, project routing is delegated to arenderPostHrefcallback, and every hardcoded[var(--color-bg)]token is rewired onto the--cb-*palette.