A CSS Paint Job for CRAN

Modernizing cran.r-project.org with a drop-in stylesheet, no HTML changes, plus a side-by-side viewer for evaluating the result.

r
cran
css
web-design
Author

James Balamuta

Published

May 11, 2026

Abstract

CRAN has been online since 1997 and its visual presentation has changed very little since. CSS CRAN Garden is a 63 KB drop-in stylesheet that modernizes every visible page on cran.r-project.org through structural CSS selectors alone, no markup changes required. It is a tribute to the original CSS Zen Garden (Dave Shea, 2003). The companion side-by-side viewer pairs a compare / mobile / theme toggle with a live CSS editor so any CRAN mirror operator can evaluate or fork the overlay against real CRAN HTML. Adoption for a mirror is one <link> line.

Background

CRAN, the Comprehensive R Archive Network, has been online since 1997 and remains the canonical package repository for the R statistical computing language. The HTML structure has served the community well for nearly three decades; the visual presentation has evolved less than the surrounding ecosystem. The earliest archived snapshot of cran.r-project.org on the Wayback Machine, from August 2000, and a slightly more polished capture from December 2000, are still readily recognizable today.

This post introduces CSS CRAN Garden, a CSS-only refresh that applies an updated typographic and color treatment to CRAN’s existing pages without altering their markup. The project is available as a live demonstration and as source on GitHub under the MIT license.

It is a tribute to the original CSS Zen Garden (Dave Shea, 2003), which proved twenty-three years ago that the same HTML could be transformed into radically different visual designs using only CSS. The same principle applies here. Every page on CRAN is restyled through structural CSS selectors. No markup changes are required.

The paint job supports a full dark mode and tracks the reader’s prefers-color-scheme. The screenshots below also adapt: if you are reading this post in dark mode, each figure below will show the dark variant of the paint job; if you are in light mode, you will see the light variant. Toggle your site theme to compare.

The CSS CRAN Garden viewer in side-by-side compare mode showing the CRAN homepage main content, in light mode. The left pane is the original page in default browser styling: a serif H1 reading 'The Comprehensive R Archive Network' followed by a layout-table with four sections (Download and Install R, Source Code for all Platforms, Questions About R, Supporting CRAN). The right pane shows the same HTML restyled by cran-modern.css: a mono accent 'CRAN' kicker, a confident Geist sans-serif hero, a numbered grid of sections, and accent download buttons for Linux, macOS, and Windows.

The CSS CRAN Garden viewer in side-by-side compare mode showing the CRAN homepage main content, in dark mode. The viewer chrome is dark; the left pane keeps the original CRAN page in its natural white styling (no overlay applied); the right pane shows the same HTML restyled by cran-modern.css with a 'CRAN' mono accent kicker, large Geist sans-serif hero on a near-black background, and accent download buttons. The side-by-side stays apples-to-apples because the left pane intentionally does not get the paint job.

Figure 1: Side-by-side compare of the CRAN homepage. The left iframe is the original; the right iframe is the same HTML restyled by cran-modern.css.

The paint job

The stylesheet is cran-modern.css: roughly 63 KB unminified, around 2,200 lines. Most rules are anchored to attribute selectors like a[href*="contrib/extra"] and tr[id^="available-packages-"], with structural :has() signatures like body:has(> div.container > dl):not(:has(> div.container > h2)) picking out specific pages by their HTML fingerprint.

Theming uses CSS custom properties for light, dark, and auto, driven by both prefers-color-scheme and an optional data-theme attribute on <html> that the viewer’s Theme button sets. Where an anchor pattern accidentally overlaps with the homepage, a :not(:has(...)) exclusion keeps page-specific rules off the homepage.

Each page gets a mono accent eyebrow above its H1 that identifies the section: CRAN for the homepage, MIRRORS · GLOBAL NETWORK for mirrors, DIRECTORY · A–Z for packages by name, DOWNLOADS · SOURCE for R sources, DOCUMENTATION · MANUALS for manuals, ABOUT · CRAN TEAM for the team page, and so on. Detection is purely structural, keyed off the unique HTML fingerprint of each page. Nothing on the server changes.

The 'Available CRAN Packages by Name' page in light mode. Top-left mono accent kicker reads 'DIRECTORY · A–Z'. Below the title, a horizontal alphabet bar spans A through Z. Each letter section opens with a large accent letter on the left and a 'PACKAGES STARTING WITH A' eyebrow on the right. Package names render as mono accent links paired with descriptions, divided by hairline rules.

The 'Available CRAN Packages by Name' page in dark mode. Top-left mono accent kicker reads 'DIRECTORY · A–Z'. Below the title 'Available CRAN Packages by Name', a horizontal alphabet bar spans A through Z. Each letter section opens with a large accent letter on the left and a 'PACKAGES STARTING WITH A' eyebrow on the right. Package names render as mono accent links paired with their descriptions in muted grey, divided by hairline rules.

Figure 2: The DIRECTORY · A–Z packages-by-name page after the paint job, with the alphabet anchor bar and per-letter eyebrows.

The viewer

I needed a fast way to evaluate changes against real CRAN markup, so the repo also ships a side-by-side viewer. It is vanilla HTML, CSS, and JavaScript: no build pipeline, no Node, no framework. Any static server will run it (python3 -m http.server is enough). The header carries four controls:

  • Compare: toggle side-by-side vs. paint-job only. Drag the centre divider to resize; double-click the divider or click the ↔︎ button to recenter.
  • Mobile: clamp both iframes to a 390 px phone-size viewport with a device-like frame.
  • Theme: cycle auto, light, and dark for both viewer chrome and iframes.
  • Edit CSS: live editor for cran-modern.css with full CSS syntax highlighting, line numbers, Tab insertion of two spaces, and ⌘S / Ctrl+S to flush the debounce immediately. Changes appear in the right iframe within a few hundred milliseconds.

Toggling Mobile clamps both iframes to a 390 px viewport (iPhone width) inside a soft device frame on a workbench-grey background. The constraint is applied identically to both panes so the before / after comparison stays apples-to-apples at mobile scale, and the surrounding workbench tone makes the constrained surface read as a deliberate phone preview rather than as a layout bug. The reflowed homepage below shows how the action grid stacks at phone width on both sides:

The viewer with mobile preview toggled on. Both panes are clamped to a 390 px phone-size viewport, centred inside a workbench-grey pane background with a soft device-frame shadow around each iframe. The homepage main content renders inside the phone surface on both sides, comparing original to paint-job at mobile scale.

The viewer with mobile preview toggled on, viewer chrome and right-pane iframe both in dark mode. Both iframes are clamped to a 390 px phone-size viewport, centred inside a dark workbench background with a soft device-frame shadow. The right pane shows the homepage main content rendered in dark mode inside the phone surface.

Figure 3: Mobile preview mode clamps both iframes to a 390 px phone-size viewport with a soft device frame so the before / after stays apples-to-apples at phone scale.

Opening Edit CSS splits the screen: the top half keeps whichever fixture is loaded, and the bottom half becomes a code editor holding the current cran-modern.css. The editor has syntax highlighting, line numbers, and Tab indentation. As you type, changes apply to the right iframe within a fraction of a second; ⌘S / Ctrl+S applies them immediately. Three buttons sit at the top of the editor tray: Download saves your edited version as cran-modern.css so you can drop it into a mirror’s <head>, Close dismisses the editor and reloads the on-disk stylesheet, and Reset restores the original without closing. Forking the project to try out your own overlay against real CRAN HTML is just opening this editor and typing:

The viewer with the live CSS editor open at the bottom. The top half shows the styled dplyr package detail page. The bottom half is the cran-modern.css source in a code editor: a line-number gutter on the left, syntax highlighting (selectors, properties, strings, custom properties, and comments each in their own color), and the editor header reading 'CSS CRAN Garden · live edit' with Download, Close, and Reset buttons on the right.

The viewer in dark mode with the live CSS editor open at the bottom. Top half shows the styled dplyr package detail page in dark mode. Bottom half is the cran-modern.css source in a dark code editor with the same syntax-highlighting palette. The editor header reads 'CSS CRAN Garden · live edit' with Download, Close, and Reset buttons on the right.

Figure 4: The live CSS editor open against the dplyr package detail page. Edits are applied to the right iframe almost as soon as you stop typing; Download saves the current editor content as cran-modern.css, Close dismisses the tray, and Reset restores the on-disk stylesheet.

How :has() does the work

The CRAN page set has no shared shell. Each generated page is essentially raw <body> content with its own structure. Instead of asking “what page am I on?” we ask “what HTML fingerprint is on this page?” via :has():

/* Sources page: a body containing a base-prerelease link, but NOT
   the homepage layout-table signature. */
body:has(a[href*="base-prerelease"]):not(:has(> h1:first-child + div > table))
  > h1:first-child::before {
  content: "DOWNLOADS · SOURCE";
}

/* Manuals page: the only container page with <h2>The R Manuals</h2>
   followed by <em>edited by the R Core Team</em>. */
body:has(> div.container > h2 + em) > div.container > h1:first-child::before {
  content: "DOCUMENTATION · MANUALS";
}

The :not(:has(...)) exclusion deserves a callout. The homepage <frameset> contains links to src/base-prerelease, which would otherwise match the sources-page selector. The exclusion identifies the homepage via its structural signature (an H1 followed by the <div> wrapping the main layout table) and keeps page-specific rules off the homepage.

One constraint worth noting: :has() cannot be nested inside another :has(). A selector such as body:has(> h3 + table:not(:has(td > i))) is invalid because the inner :has() is nested in the outer one, even through an intermediate :not(). The CSS parser silently invalidates the entire selector list, so a single non-conforming selector causes the whole rule to be dropped. This silent failure mode is worth knowing about when a comma-separated selector list stops applying after a small edit.

The CRAN Mirrors page in light mode, restyled. Top-left mono kicker reads 'MIRRORS · GLOBAL NETWORK'. Below the title and lead paragraphs, countries appear as numbered sections with mono '01', '02', '03' eyebrows. Each mirror URL is preceded by a green filled dot for HTTPS or a grey dot for plain HTTP. Host descriptions appear in muted grey to the right of each URL.

The CRAN Mirrors page in dark mode, restyled. Top-left mono kicker reads 'MIRRORS · GLOBAL NETWORK'. Below the title and lead paragraphs, countries appear as numbered sections with mono '01', '02', '03' eyebrows. Each mirror URL is preceded by a green filled dot for HTTPS or a grey dot for plain HTTP. Host descriptions appear in muted grey to the right of each URL.

Figure 5: The MIRRORS · GLOBAL NETWORK page after the paint job: country sections with numbered eyebrows, mono URLs, and a green or muted dot indicating HTTPS versus plain HTTP.

A per-page treatment, briefly

Every visible page got its own treatment. A short tour:

  • Homepage: numbered action grid (Download R, Source Code, Questions, Supporting CRAN), accent-tinted “latest release” card, accent download buttons for the platform binaries.
  • Packages by name: alphabet anchor bar, letter-section eyebrows, mono package names paired with descriptions.
  • Package detail (/web/packages/<pkg>/): hero title from the package summary, metadata in a clean two-column grid, dependency chips for Depends / Imports / Suggests.
  • Task views index: numbered topic table with hover state.
  • Mirrors: numbered country sections, mono URLs with a green / grey security dot, muted host descriptions.
  • R Sources: numbered chapter sections (‘Official releases’, ‘Snapshots’) with release cards and accent-tinted tarball download buttons.
  • CRAN Team: PEOPLE and ROLES eyebrows, current vs. former members rendered as side-by-side cards, each role section with member chips and a tinted email-CTA button.

Most of those treatments are visible in the screenshots above: the homepage in Figure 1, the by-name directory in Figure 2, the mirrors page in Figure 5, the package detail page paired with the live editor in Figure 4, and the homepage again at phone scale in Figure 3. The CRAN Team page is the one that has not appeared yet (Figure 6):

The CRAN Team page in light mode. Mono kicker reads 'ABOUT · CRAN TEAM'. H2 sections labeled 'PEOPLE' and 'ROLES' as eyebrows. Under PEOPLE, two side-by-side prose cards list the current and former team members; the current-team card has an accent-tinted background. Below is the start of the 'By Task' section with a numbered role header.

The CRAN Team page in dark mode. Mono kicker reads 'ABOUT · CRAN TEAM'. H2 sections labeled 'PEOPLE' and 'ROLES' as eyebrows. Under PEOPLE, two side-by-side prose cards list the current and former team members on a dark background; the current-team card has an accent-tinted dark background. Below is the start of the 'By Task' section with a numbered role header.

Figure 6: The ABOUT · CRAN TEAM page with PEOPLE and ROLES section eyebrows, current and former team cards under PEOPLE, and the start of the first numbered role section.

Limitations

CSS alone cannot do everything. The homepage uses a deprecated <frameset>, which the stylesheet can theme inside each frame but not at the seams between them; those inter-frame borders remain at the browser’s default light gray in dark mode because CSS on the parent document cannot reach into the frameset chrome itself. The packages-by-name page ships roughly 23,000 rows in a single response, so while CSS can visually hide rows it cannot paginate or reduce the bytes shipped; proper pagination needs JavaScript. Layout tables on the homepage would benefit from real ARIA landmarks and roles for screen readers, but adding those is a markup change rather than a styling change. Anything behavioral, such as a search box, copy-to-clipboard buttons, or dependency graphs, is JS-driven and out of scope.

Adoption

For any CRAN mirror, adoption is one line in the existing <head>:

<link rel="stylesheet" href="/cran-modern.css">

That is all. No HTML changes are needed because the stylesheet targets the attributes and structure the existing R generators already emit.

Source

If you mirror CRAN and want to experiment, fork the repo, edit the stylesheet against the bundled fixtures (or your own snapshots) in the live editor, and drop the result into your page templates. The HTML is never modified.

References