Bulma for Go
Scaffold Bulma — a modern, classes-based, CSS-only framework (MIT, no JavaScript, no jQuery) — into a Go web app. Assets are always downloaded at setup time and embedded into the binary with //go:embed. The running binary never fetches anything from the network.
Bulma styles components through utility and component classes (class="button is-primary", class="column is-half", class="navbar"). Bulma 1.0+ (released Feb 2024) ships CSS custom properties for theming, so colors, radii, fonts, and spacing can be customized without Sass or a Node build step.
Bulma intentionally ships zero JavaScript, so interactive components (navbar burger, modal, dropdown, tabs) need a small amount of vanilla JS to actually work. This skill scaffolds a self-contained app.js covering those patterns.
When to Use
- User asks for a polished classes-based HTML UI in a Go app without React/Vue/Angular
- User mentions "Bulma", "no Node", "no Sass", or wants a Bootstrap-like experience with a cleaner class system
html/template,templ, or*.gohtmlfiles exist but no CSS framework is presentbulma.min.cssis already in the repo and needs wiring or upgrading
Prerequisites
- go.mod exists: The project must be a Go module. Abort otherwise.
- Go ≥ 1.16:
//go:embedrequires Go 1.16+. Read thegodirective ingo.modto verify. - HTTP server present or planned: Bulma renders HTML served over HTTP. Confirm with the user if unclear.
Core Principle
Zero runtime dependencies. The binary must run offline after go build. Never reference cdn.jsdelivr.net, unpkg.com, or cdnjs.cloudflare.com from rendered HTML. Download once at setup time, embed, commit, ship.
Asset Source
Use the pinned npm-on-jsdelivr URL — it is immutable per version and fetched only at setup:
https://cdn.jsdelivr.net/npm/bulma@<VERSION>/css/bulma.min.css
Alternative (also acceptable, also pinned): the GitHub release zip:
https://github.com/jgthms/bulma/releases/download/<VERSION>/bulma-<VERSION>.zip
The zip contains css/bulma.min.css and the unminified sources. For a Go workflow the single bulma.min.css from jsdelivr is simpler.
Do not use:
unpkg.com/bulma/...without a version — resolves tolatest, not reproduciblecdnjs.cloudflare.com/.../latest/...— same problem- Bulma's GitHub
masterbranch raw URLs — moving target, not a release artifact
To find the current version: curl -s https://api.github.com/repos/jgthms/bulma/releases/latest | grep tag_name
Workflow: Scaffold Bulma into a Go project
Step 1: Choose asset layout
| Project shape | Recommended layout |
|---|---|
Flat app (main.go at root) |
web/static/bulma.min.css, web/static/app.js, web/embed.go |
internal/ + layered (cmd/..., internal/...) |
internal/ui/static/bulma.min.css, internal/ui/embed.go |
| Library with optional web UI | web/ui/static/... in a separate package |
Prefer internal/ui/ when there is already an internal/ tree. Otherwise use web/.
Step 2: Download pinned assets
VERSION=1.0.4 # confirm latest via the release API before running
DEST=internal/ui/static
mkdir -p "$DEST"
curl -fsSL -o "$DEST/bulma.min.css" "https://cdn.jsdelivr.net/npm/bulma@${VERSION}/css/bulma.min.css"
-f is required: without it curl exits 0 on a 404 and writes jsdelivr's plain-text error page into bulma.min.css, silently shipping a broken stylesheet. Abort the workflow if this command exits non-zero.
The jsdelivr npm path uses a bare version like 1.0.4 (no v prefix); GitHub release tags happen to match (also 1.0.4, no v).
Step 3: Record the version and checksums
Create a BULMA_VERSION file (or header comment in embed.go) so upgrades are traceable:
bulma 1.0.4
bulma.min.css sha256: <output of shasum -a 256 bulma.min.css>
app.js sha256: <output of shasum -a 256 app.js>
Step 4: Create the embed package
// internal/ui/embed.go
package ui
import "embed"
// Static holds Bulma's CSS and the vanilla JS helpers.
// See ../../BULMA_VERSION for the pinned release.
//
//go:embed static/bulma.min.css static/app.js
var Static embed.FS
If a theme.css is added later (see Theming), extend the directive:
//go:embed static/bulma.min.css static/app.js static/theme.css
Step 5: Wire the static file handler
// in route setup (main.go or internal/server/routes.go)
import (
"io/fs"
"net/http"
"yourmod/internal/ui"
)
func registerStatic(mux *http.ServeMux) {
sub, err := fs.Sub(ui.Static, "static")
if err != nil {
panic(err) // compile-time constant; impossible at runtime
}
fileServer := http.FileServer(http.FS(sub))
mux.Handle("/static/", http.StripPrefix("/static/", immutable(fileServer)))
}
// immutable marks embedded assets as permanently cacheable. This is safe
// precisely because they are embedded: the only way an asset changes is a new
// binary, which is a new deployment. Without this, every page load re-fetches
// every asset -- embed.FS entries carry a zero mtime, so http.FileServer cannot
// send Last-Modified and the browser has nothing to revalidate against.
func immutable(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
h.ServeHTTP(w, r)
})
}
If assets are served from a path that does not change between releases, add a
cache-busting query or a content-hashed filename before using immutable;
otherwise clients keep the old file for a year.
Step 6: Create a minimal Bulma-styled template
<!-- internal/ui/templates/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<script src="/static/app.js" defer></script>
</head>
<body>
<nav class="navbar is-primary" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/"><strong>{{.Title}}</strong></a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="mainMenu">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="mainMenu" class="navbar-menu">
<div class="navbar-end">
<a class="navbar-item" href="/about">About</a>
</div>
</div>
</nav>
<section class="section">
<div class="container">
<h1 class="title">{{.Title}}</h1>
<form method="post" action="/save">
<div class="field">
<label class="label">Name</label>
<div class="control">
<input class="input" name="name" required>
</div>
</div>
<div class="field">
<label class="label">Email</label>
<div class="control">
<input class="input" type="email" name="email">
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Save</button>
<button class="button" type="button" data-target="aboutModal">About</button>
</div>
</div>
</form>
</div>
</section>
<div id="aboutModal" class="modal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">About</p>
<button class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body">
<p>Styled by Bulma — embedded in the binary, served offline.</p>
</section>
</div>
</div>
</body>
</html>
Embed templates alongside assets:
//go:embed templates/*.html
var Templates embed.FS
Parse at startup with template.ParseFS(ui.Templates, "templates/*.html").
Step 7: Verify zero-dependency build
# 1. Build check. Use `.` (the main package), not `./...`: with `-o <file>`,
# a package set containing more than one package fails with
# "cannot write multiple packages to non-directory app".
go build -o app .
# 2. Runtime check. Replace 8080 with the port your app actually listens on.
go run . &
APP_PID=$!
sleep 1
curl -fsI http://localhost:8080/static/bulma.min.css | head -1 # expect 200
curl -fsI http://localhost:8080/static/app.js | head -1 # expect 200
kill "$APP_PID"
# 3. Disable the network and rerun — it still works (assets are embedded).
Workflow: Upgrade Bulma
- Check the latest release:
curl -s https://api.github.com/repos/jgthms/bulma/releases/latest | grep tag_name - Re-run Step 2 with the new version
- Update
BULMA_VERSIONand checksums (Step 3) - Run the app; review the Bulma changelog for breaking changes (Bulma 1.x is stable; minor releases are additive but always read the changelog)
- Commit as a single changeset so the bump is traceable
Pattern: Vanilla JS helpers (app.js)
app.js ships alongside this file. Copy it verbatim to the project's
static/app.js -- do not retype or paraphrase it:
cp "${CLAUDE_SKILL_DIR}/app.js" internal/ui/static/app.js
It covers navbar burger, modal open/close, dropdown toggle, and tabs -- Bulma's four interactive patterns. Zero dependencies, plain DOM API, guarded so each block is a no-op when the corresponding markup is absent. Bulma ships no JS of its own by design; these are the standard idioms from https://bulma.io/documentation/.
Pattern: Pair with templ
If **/*.templ files exist, render via templ components instead of html/template. The embed and handler steps are unchanged — only the rendering layer differs.
// internal/ui/layout.templ
package ui
templ Layout(title string) {
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{ title }</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<script src="/static/app.js" defer></script>
</head>
<body>
<section class="section">
<div class="container">
{ children... }
</div>
</section>
</body>
</html>
}
If templ is not yet set up in the project, defer to the go-tool skill to add it via go get -tool github.com/a-h/templ/cmd/templ@latest, then return here.
Pattern: Theming via CSS custom properties
Bulma 1.0+ exposes its design tokens as CSS custom properties (HSL components for colors, plus radii, fonts, spacing). Override them in a sibling theme.css loaded after bulma.min.css:
/* internal/ui/static/theme.css */
:root {
--bulma-primary-h: 217;
--bulma-primary-s: 71%;
--bulma-primary-l: 53%;
--bulma-link-h: 217;
--bulma-link-s: 71%;
--bulma-link-l: 53%;
--bulma-radius: 4px;
--bulma-radius-small: 2px;
--bulma-radius-large: 6px;
--bulma-family-primary: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
<link rel="stylesheet" href="/static/bulma.min.css">
<link rel="stylesheet" href="/static/theme.css">
Add theme.css to the //go:embed directive. For the full list of custom properties, search bulma.min.css for --bulma- or see the customize colors docs.
Deeper customization (new component variants, removing unused modules to shrink the bundle) requires Sass and Node — out of scope for this skill. If the user asks for that, push back: the value proposition here is "no Node toolchain", and tree-shaking saves at most ~150KB on an already-cached file.
Anti-Patterns
| Don't | Do |
|---|---|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma/..."> in rendered HTML |
Download once at setup, embed, serve from /static/ |
npm install bulma + a Sass build step |
Pure Go workflow — download bulma.min.css, write app.js, embed, done |
Shipping unminified bulma.css in the binary |
Always use bulma.min.css |
Inline <style> blocks duplicating Bulma utilities |
Use Bulma helper classes: .has-text-centered, .mt-4, .is-flex, etc. |
| Mixing Bootstrap and Bulma classes in the same page | Pick one. For Bootstrap, defer to the html-first-frontend agent |
Forgetting defer on <script src="/static/app.js"> |
defer is required — the helpers query the DOM at load time |
| Reaching for jQuery or Alpine for navbar/modal | The scaffolded app.js already covers Bulma's four interactive patterns |
Reference Layout
A completed setup looks like:
.
├── go.mod
├── main.go
├── BULMA_VERSION
└── internal/
└── ui/
├── embed.go
├── render.go
├── static/
│ ├── bulma.min.css
│ ├── app.js
│ └── theme.css (optional)
└── templates/
├── layout.html
└── index.html
Error Handling
| Condition | Action |
|---|---|
No go.mod |
Abort: "Not a Go module — run go mod init first." |
Go version < 1.16 |
Abort: "//go:embed requires Go 1.16+. Update the go directive in go.mod." |
curl -f exits non-zero for bulma.min.css |
Abort — do not proceed with a partial download. Verify the version exists on npm: https://www.npmjs.com/package/bulma. The release tag and the npm version should match. |
| User insists on CDN at runtime | Push back: the skill's reason for existing is offline, zero-dep, fully standalone binaries. If they still want CDN, decline and let them wire it manually without this skill. |
| Bulma assets already present in the repo | Skip download; verify checksums against BULMA_VERSION and proceed to wire the handler, write app.js if missing, and add the template. |