A hand-rolled linktree alternative — pure static HTML/CSS/JS, no build step. Drop on any shared host via SFTP and edit data/links.json to update. Features - File-driven content via data/links.json (links, projects, YouTube, client tiles, portfolio pieces) - Two interchangeable templates: editorial (Fraunces + paper + vermilion) and swiss (Archivo grotesque, all-caps poster) - Auto/light/dark theme toggle with no-flash boot script - Auto-fetched favicons via Google S2 (with image-URL override) - Lazy YouTube facades (no third-party JS until clicked) - Adaptive client-logo grid - Scroll-triggered reveal animations - ~40 KB total payload, ~12 KB gzipped The repo ships links.example.json as a demo; data/links.json is gitignored so personal content stays out of the public repo.
14 KiB
dlstack
A hand-built, file-driven personal link index. Drop it on any shared host
via SFTP — no build step, no framework, no database. To update the page,
edit data/links.json and re-upload.
Live at https://www.joelbrock.org.
Setup
The repo ships with data/links.example.json (Ada Lovelace placeholder
data). The app reads data/links.json first, and falls back to the
example if there's no real file — so a fresh clone renders immediately.
To customize:
cp data/links.example.json data/links.json
# edit data/links.json — your real content
data/links.json is gitignored, so your personal content stays out of
the public repo. Upload data/links.json to your host alongside the
other files when you deploy.
File layout
dlstack/
├── index.html shell + font preloads + no-flash boot script
├── favicon.svg the dL mark, ink/paper adaptive
├── data/
│ ├── links.example.json committed example — used as fallback
│ └── links.json your real content (gitignored)
└── assets/
├── css/
│ ├── styles.css editorial template (default)
│ └── swiss.css swiss template (overrides, scoped)
└── js/
└── app.js loader + renderers
Total payload: ~40 KB across all assets (uncompressed). Gzipped: ~12 KB.
Deploying
Upload the whole directory via SFTP/FTP to the web root of any shared host. That's it — no PHP, no Node, no DB.
To update content: edit data/links.json locally, upload, done.
If you're behind Cloudflare during active development, enable Caching → Configuration → Development Mode in the CF dashboard to bypass the edge cache for 3 hours. Otherwise purge edge cache after each asset change.
data/links.json schema
{
"profile": { … }, // who you are
"theme": { … }, // colors + which template
"sections": [ … ], // groups of items
"social": [ … ], // pill buttons under the hero
"footer": { … } // bottom strip
}
profile
| field | type | description |
|---|---|---|
name |
string | Hero name. Last word is set in italic accent. |
handle |
string | Optional. Shown in the top marker (e.g. @you). |
tagline |
string | Italic subtitle. Words separated by · get the accent color. |
bio |
string | One-paragraph intro under the tagline. |
location |
string | Currently unused after the live clock was removed; safe to leave or omit. |
theme
| field | type | values | description |
|---|---|---|---|
accent |
string | hex color, e.g. "#E8482C" |
Single sharp accent color used throughout. |
template |
string | "editorial" (default) or "swiss" |
Visual treatment. See Templates. |
mode |
string | unused; kept for future use | Theme toggle (auto/light/dark) is controlled by the corner pill, not this field. |
sections
An ordered array of groups. Each section renders with a numbered masthead
(№ 01 / Sites) and a grid of items.
{
"id": "sites", // required, unique slug
"label": "Sites", // required, the section title
"kicker": "Where I live online", // optional italic tagline
"layout": "clients", // optional — see Layouts below
"items": [ … ] // required, array of items
}
Layouts
layout |
When to use | Grid behavior |
|---|---|---|
| omitted | Default bento mix | 12-column asymmetric grid (link cards span 4/12, projects/youtube span 6/12, featured spans 8/12, portfolio spans 12/12) |
"clients" |
Logo wall of clients/partners | Auto-flowing square tiles, minmax(108–124px, 1fr) |
layout: "clients" is also auto-detected if the first item in the section
has type: "client".
Items
Every item lives in a section's items array and has a type field. The
five available types are documented below.
type: "link" — standard link card
{
"type": "link",
"title": "Columinate",
"url": "https://columinate.com",
"description": "Cooperative Technology Solutions",
"image": "https://example.com/logo.png", // optional — overrides auto-favicon
"featured": false // optional
}
| field | required? | notes |
|---|---|---|
title |
yes | Card headline. |
url |
yes | Where the card links to. Opens in new tab. |
description |
no | Smaller line under the title. |
image |
no | Custom logo URL. Fills the favicon slot edge-to-edge (object-fit: cover). If omitted, a favicon is auto-fetched via Google's S2 service (google.com/s2/favicons?sz=128). |
icon |
no | Alias for image. |
featured |
no | Reserved; not currently styled differently for link cards. |
type: "card" — project card
Bigger, prose-friendly card with tags.
{
"type": "card",
"title": "CoVote",
"url": "https://covote.org",
"description": "Anonymous election platform for co-ops.",
"tags": ["democracy", "co-ops"],
"featured": true // optional — makes it the hero card
}
| field | required? | notes |
|---|---|---|
title |
yes | Serif headline (Fraunces in editorial; Archivo 900 caps in Swiss). |
url |
no | If present, the card becomes a link. |
description |
no | Paragraph under the title. |
tags |
no | Array of strings — small monospaced chips. |
featured |
no | When true, card spans 8/12 columns AND fills with the accent color (paper text on vermilion). Use sparingly — one per section. |
type: "youtube" — embedded video
Renders a lazy facade (thumbnail + play button). The real YouTube iframe only loads when clicked, so the page stays fast even with many videos.
{
"type": "youtube",
"id": "42EXoE7pNAc",
"title": "The 7 Cooperative Principles Song"
}
| field | required? | notes |
|---|---|---|
id |
yes | YouTube video ID (the v= parameter from the URL). |
title |
yes | Shown over the thumbnail and as the iframe's accessible title. |
type: "client" — logo tile
Used in client/partner walls. Square logo tile with a small caption
below. Best in a section with layout: "clients".
{
"type": "client",
"title": "Columinate",
"url": "https://columinate.coop",
"image": "https://columinate.coop/i/logo-square.png" // optional
}
| field | required? | notes |
|---|---|---|
title |
yes | Caption below the tile. Long names wrap inside the column width. |
url |
no | If present, the tile becomes a link. |
image |
no | Custom logo. If omitted, an auto-favicon is used (rendered at 60% of the tile, contained). |
icon |
no | Alias for image. |
type: "portfolio" — wide design/showcase
Always spans the full row. Use for featured design pieces, case-study images, or any visual you want to feature large.
{
"type": "portfolio",
"title": "Brand identity — Acme Co-op",
"url": "https://example.com/case-study",
"image": "https://example.com/portfolio/acme-hero.jpg",
"description": "Logo system, packaging, signage",
"ratio": "5:2" // optional, default "5:2"
}
| field | required? | notes |
|---|---|---|
image |
yes | The image to display. Fills the card with object-fit: cover. |
title |
no | Caption headline below the image. |
description |
no | Caption subline. |
url |
no | If present, the card becomes a link. |
ratio |
no | Override aspect ratio. Format "W:H" (e.g. "16:9", "3:1", "4:3"). Default "5:2" (wide, not tall). |
social
Pill buttons that appear under the hero bio.
[
{ "label": "GitHub", "url": "https://github.com/you", "icon": "github" },
{ "label": "LinkedIn", "url": "https://linkedin.com/in/you", "icon": "linkedin" },
{ "label": "Email", "url": "mailto:you@example.com", "icon": "mail" },
{ "label": "RSS", "url": "/feed.xml", "icon": "rss" }
]
| field | required? | notes |
|---|---|---|
label |
yes | Text shown next to the icon. |
url |
yes | Link target. |
icon |
no | One of the built-in icon names (see below). Unknown values fall back to a generic chain-link icon. |
Built-in icons: github, linkedin, mail, rss, link.
The icons are not from a library — they're hand-coded inline SVGs in the
SOCIAL_ICONS object inside assets/js/app.js. Inline because:
- No extra HTTP requests
- Tint cleanly with
currentColorso they inherit the link's text color (works in both editorial + Swiss templates, light + dark modes) - Total weight for all 5 is ~2 KB
Where to find more icons
| Source | Best for | URL |
|---|---|---|
| Simple Icons | Brand logos — GitHub, Mastodon, Bluesky, YouTube, Instagram, X/Twitter, Substack, Discord, Patreon, Buy Me a Coffee, etc. (3000+ brands) | https://simpleicons.org |
| Lucide | Generic UI icons — mail, rss, link, phone, calendar, etc. (1500+, MIT-licensed) | https://lucide.dev/icons |
| Phosphor | 9000+ icons with multiple weights (regular / bold / thin / duotone) | https://phosphoricons.com |
| Heroicons | Tailwind's set, clean and minimal | https://heroicons.com |
For social/brand logos → Simple Icons. For generic UI marks (mail, rss, link, etc.) → Lucide or Phosphor.
How to add a new icon
-
Find the icon on one of the sites above.
-
Click it and choose "Copy SVG" (or download the SVG).
-
Open
assets/js/app.jsand find theSOCIAL_ICONSobject. -
Paste the SVG as a new entry, keyed by a short name. Strip any hardcoded
fill="..."orstroke="..."colors and replace them withcurrentColorso it inherits the link color.const SOCIAL_ICONS = { github: '<svg viewBox="0 0 24 24" fill="currentColor">…</svg>', linkedin: '<svg viewBox="0 0 24 24" fill="currentColor">…</svg>', mail: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">…</svg>', rss: '<svg viewBox="0 0 24 24" fill="currentColor">…</svg>', link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">…</svg>', // Newly added — Bluesky from Simple Icons: bluesky: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="…"/></svg>' }; -
Reference the new icon by its key in
data/links.json:{ "label": "Bluesky", "url": "https://bsky.app/profile/you", "icon": "bluesky" } -
Upload
app.jsandlinks.json(and purge Cloudflare cache or use Development Mode).
The fallback for any unknown icon value is a generic chain-link icon,
so it'll still render even if you forget step 4.
footer
{
"copy": "Hand-built. No trackers.",
"year": "auto"
}
| field | required? | notes |
|---|---|---|
copy |
no | Left side of the footer. |
year |
no | Currently unused — the right side auto-displays the current year and your name. |
Templates
theme.template selects the visual treatment. Both stylesheets are
always loaded, but the Swiss rules are scoped to
:root[data-template="swiss"] so only one is "active" at a time.
"editorial" (default)
Magazine/poster aesthetic.
- Type: Fraunces (variable serif, italic for accents) + Geist (sans body) + Geist Mono (UI/eyebrows)
- Mood: warm paper background, vermilion accent, subtle grain texture, asymmetric bento grid, magazine-style section numbers, italic last-name in the hero, floating typographic asterism
- Cards: rounded corners (14px), thin rules, lift + corner-arrow on hover
"swiss"
Müller-Brockmann tribute. Akzidenz/Univers spirit with Archivo as the free variable substitute.
- Type: Archivo only, weights 400/500/600/700/900, all-caps headlines
- Mood: near-pure white paper, near-pure black ink, true Swiss red (
#DC2127by default, overridable viatheme.accent) - Geometry: zero border-radius everywhere, 2–4px black rules, solid red disc replaces the typographic asterism
- Cards: invert on hover (white → solid black with paper text). Featured cards are solid red, invert to black on hover.
- Animations: no spin, no italic — only sharp pop-in for the geometric mark
To switch:
"theme": { "accent": "#E8482C", "template": "swiss" }
Choice is also cached in localStorage (dlstack-template) so reloads
don't flash the wrong template before JSON parses.
Theme (light / dark / auto)
A small pill in the top-right cycles Auto → Light → Dark → Auto on
click. "Auto" follows prefers-color-scheme. Choice persists in
localStorage (dlstack-theme). An inline boot script in <head>
applies the saved choice before CSS paint, so there's no flash on reload.
The accent color (theme.accent in JSON) is applied as a CSS custom
property at runtime and respects both light and dark modes.
Reserved sub-paths
data/links.json— contentassets/css/styles.css— editorial template + baseassets/css/swiss.css— swiss template overridesassets/js/app.js— rendererfavicon.svg— the dL mark
Don't rename these without updating the references in index.html.
Local preview
From the project root:
python3 -m http.server 8765
# then open http://127.0.0.1:8765
Any static file server works; CORS isn't an issue since everything is same-origin.