Hygguile
cozy and professional user-interfaces for everyone
Hygguile is an opinionated, batteries-included UI framework written in Guile Scheme. It gives you a small, expressive domain-specific languageDSL for building cozy and professional interfaces from symbolic expressionsS-expressions, Scheme XML: XML written as S-expressionsSXML and modern Cascading Style SheetsCSS.
It is built on Olive CSS: a utility-class framework whose syntax will feel familiar to anyone who has used Tailwind, but which is plain Cascading Style SheetsCSS rather than JavaScript.
Every component renders to real HyperText Markup LanguageHTML on the server and is enhanced in the browser only where that helps. Pages stay fast, accessible, and fully usable without JavaScript.
Typography
Type⌄
The scale is a handful of headings, a lighter subheading, body copy with its inline elements, and the two faces the framework sets code in.
Headings
Cozy by default
Settings and preferences
A card title
A quieter heading
Secondary and serif
A lighter secondary heading
Read slowly
An editorial note
Read the API documentation for the details.
Body and inline elements
Cozy copy with importance, emphasis and a highlight.
Press Ctrl then l, read ok, export GC_ROOT, meet HTML, or write H2O and E = mc2.
Quotations
Good design is as little design as possible.
Simplicity is about subtracting the obvious and adding the meaningful.
Mono and code
$ guix shell -m manifest.scm
Call (render page) from the request handler.
Preformatted
;;; A simple Guile Scheme HTTP server
(use-modules (web server))
(define (my-handler request request-body)
(values '((content-type . (text/plain)))
"Hello World!"))
(run-server my-handler)Cards
Layout⌄
Header, body and footer
Card title
NewCards group related content with a header, a body and a footer.
An expandable card
Opens in place
Layout⌄
The card this page is built out of: a native disclosure whose header is the control, so it opens and closes without a line of script.
Badges
A badge is a short label with a semantic tint; a card header is a natural home for one.
Containers
Layout⌄
first element
second element
third element
first element
second element
third element
A glass panel is a surface you can see through: a translucent tint over a backdrop blur, so what sits behind the panel is softened rather than hidden. Nothing in it is cut short by default -- a panel decides that with #:clip? -- and the ink of a line flush with the glass stays whole. The washes are the wallpaper trick -- a few oversized, blurred discs of colour thrown across the glass -- and they are optional, as is their drift.
Static washes
The discs are there, but nothing moves. This is the panel without #:drift? -- the washes are simply still.
Drifting washes
The same panel with #:drift? #t, and a pace and a distance of its own: #:pace and #:distance are the page's to choose, so these discs cross their corner in a few rems while the hero's wander for half a minute.
Frosted, not opaque
Any content can live behind the glass.
(glass-panel (list (h3 "Frosted, not opaque")
(p "Any content can live behind the glass."))
#:washes (list (glass-wash '(bg-naples-yellow-500/75 w-72
h-72
left-8
top-4)
#:delay -4)
(glass-wash '(bg-jeans-blue-500/50 w-64 h-64
right-8
bottom-4)
#:delay -12))
#:drift? #t)Colors
Foundations⌄
Hygguile offers colors in the 100, 300, 500, 700 and 900 variants. Some of these colors are essential for the hygguile components, however not all of them are.
Gradients
Foundations⌄
A gradient is a container whose background image you describe with colour steps and a direction. Stops accept any CSS colour, oklch with an alpha channel included, so a gradient can stay inside the same palette as the rest of the interface.
Pass a list of stops and a direction and you have a panel; the same call is the slide behind a headline, or a hairline of colour (this page marks every section with one) — the container classes decide which.
hygge morning
(linear-gradient (p "hygge morning" #:color '(text-onyx-500)
#:weight '(font-bold))
#:direction 'top
#:container-class (mk-class '(flex items-end w-full
min-w-64 min-h-32
p-3 rounded-2xl))
#:steps '("oklch(1 0.0164 91.55 / 1)" "oklch(0.5777 0.4081 93.34 / 0.7)"))Layout & utility
Layout⌄
Structural primitives that keep spacing and responsive behaviour consistent.
Stack
Account
Signed in as Ada.
Cluster
Center
A measured reading width, centred in its column.
Aspect ratio
16 / 9
Grid
A cell
Another cell
Split
Start
End, two thirds
Utility & composition
Clipboard
Density
Truncation
The quick brown fox jumps over the lazy dog, and then keeps going for long enough that a card would grow a second and a third line if nothing stopped it.
Motion
Reads the motion tokens, so reduced-motion turns it off.
RTL and language
مرحبا بالعالم
Responsive
Resize the window: only one of these badges is shown.
Buttons
Controls⌄
Every variant is a native <button>: the fill and the shadow are utilities, the label and the focus ring come from the element itself.
Variants
Form buttons
Icon and label
Tonally deep fills
Form fields
Controls⌄
A field is a native control with a bound label, helper copy and an error slot; the whole form below is server-rendered markup, not a widget.
Wired by the form-field abstraction.
The reveal control is a progressive enhancement, not the field.
- SSR
A native number input with explicit bounds.
The prefix and the suffix are written beside the value, not inside it.
Enter the code we sent you.
The number beside the field is the value, written out.
A read-only rating is one image with one accessible name.
There is a problem
Forms
Controls⌄
Browsers and password managers can fill this in.
(input #:label "Your name:" #:name "full-name" #:type 'name
#:autocomplete "name" #:placeholder "Ada Lovelace"
#:help-text "Browsers and password managers can fill this in.")(textarea #:label "some textarea:" #:name "textarea-1"
#:autocomplete "off")(select #:label "Language:" #:name 'user-lang
#:options (list (optgroup #:label "Americas"
#:options (list (option #:value 'en_US
#:content "English (US)"
#:selected-value "en_US")))
(optgroup #:label "Europe"
#:options (list (glyph-option #:value 'nl_NL
#:glyph "🇳🇱"
#:content "Nederlands"
#:selected-value "en_US")
(glyph-option #:value 'es_ES
#:glyph "🇪🇸"
#:content "Español"
#:selected-value "en_US")))))A select states its own selection: exactly one option carries the selected attribute, written from the value the server rendered. The rows here are built with symbols and compared with strings, which used to lose the selection between the two; an option whose value the menu does not have falls back to the first row a reader can actually pick, and rows grouped under an optgroup are still rows -- the headings are the browser's own, and the group a reader cannot choose never holds the selection.
(checkbox #:label "I accept the terms" #:name "terms" #:required #t)
(radio #:label "Small" #:name "size" #:value "s" #:checked #t)
(switch #:label "Notifications" #:name "notify" #:checked #t)
(range #:label "Volume" #:name "volume" #:min 0 #:max 11 #:value 7)
A form is the wrapper for the most common case of all: the browser serialises the fields and reloads the page with the answers. The labels, the native validation and the submit button are all markup, so the form still works with JavaScript disabled.
The example posts nothing: this showcase is a static page, so it uses a harmless GET. A real signup would post to your own endpoint.
(form (list (fieldset (list (input #:label "Your email:" #:name "email"
#:type 'email #:autocomplete "email"
#:required #t
#:help-text "Used once, to confirm the subscription.")
(select #:id "plan" #:label "Plan:"
#:name 'plan
#:help-text "You can change this later, at any time."
#:options (list (option #:value "cozy"
#:content "Cozy (free)")
(option #:value "calm"
#:content "Calm (€ 9 / month)")))
(checkbox #:label "I agree to the terms"
#:name "terms" #:required #t))
#:legend "Subscribe to the newsletter"))
#:action "#" #:method 'get #:submit "Subscribe")Validation
Controls⌄
A field is in one of three states, and says which: nothing yet, wrong, or right. An error is announced and named (aria-errormessage as well as aria-describedby); a success is announced politely; and the control's own border follows the state, so a reader can find the one field that needs them without reading every line.
There is a problem
The form above checks itself in the browser too: #:validate? borrows the browser's verdict and writes it into the field's error slot, so the page's own sentence is the one a reader reads. Nothing about it needs a script to be safe -- with scripting off the browser still refuses the form, with its own bubble.
No state yet: a field is not wrong before it is filled in.
Enter an email address.
Looks good.
Pick a range.
Disclosure
Controls⌄
Native details element
Built on the browser's own disclosure widget.
Zero JavaScript
Accordions keep working with scripting disabled.
Hover or focus for a tooltip:
Enter a reproducible development shellguix shellDetail lists
Data⌄
- foo:
- bar
- abcdefghij:
- 1234567890
- some key:
- some value
Data tables
Data⌄
Tabular data deserves real table markup: columns are declared with <th> and an explicit scope, the rows live in <tbody>, and the totals sit in <tfoot>, so the header that describes a cell is never guessed by a screen reader.
A wide table is also the classic way to break a phone layout. Every table therefore arrives wrapped in a region you can scroll sideways, named for assistive technology and reachable with the keyboard.
| Day | Coffees | Pastries | Takings |
|---|---|---|---|
| Monday | 42 | 18 | € 248.50 |
| Tuesday | 51 | 22 | € 301.75 |
| Wednesday | 47 | 19 | € 274.20 |
| Thursday | 58 | 26 | € 349.90 |
| Friday | 73 | 31 | € 438.05 |
| Week | 271 | 116 | € 1 612.40 |
| Plan | Price |
|---|---|
| Cozy | 9 |
| Calm | 14 |
| Total | 23 |
(table (list (thead (list (tr (list (th "Plan" #:scope 'col)
(th "Price" #:scope 'col
#:numeric #t)))))
(tbody (list (tr (list (td "Cozy") (td "9" #:numeric #t)))
(tr (list (td "Calm") (td "14" #:numeric #t)))))
(tfoot (list (tr (list (th "Total" #:scope 'row)
(td "23" #:numeric #t))))))
#:caption "Plans")Sorting is not a rendering trick, it is a question a page asks the server: in which order? A sortable header cell is therefore an ordinary link that carries the order in the query string, and the cell announces the state it is in with aria-sort. Clicking a column is a plain navigation: it works with JavaScript disabled, a reload reproduces the order, and the URL can be shared or bookmarked.
Only a server can answer that link, and this showcase is a static page, so what you see below is the markup it produces: each header carries ?hygguile-table-sort= and ?hygguile-table-dir=, and the current column says which way it is going. The names are prefixed so that a sort or a dir parameter of the page's own is never read as the table's state. examples/sortable-table.scm runs the other half -- a server that reads the order back with parse-sort and re-renders the rows in it.
Data display
Data⌄
Carousel
Media⌄
- VENI VIDI VICI
- Alea iacta est
- Carpe diem
- COGITO ERGO SUM
- IN VINO VERITAS
- ACTA NON VERBA
- DEUS EX MACHINA

Hygguile Logo 
WikiMusic Logo 



Media
Media⌄


avatar with initials fallback


Navigation
Navigation⌄
Tabs are pure CSS: no JavaScript required.
Selecting a label reveals the matching panel.
Fly-out
Navigation⌄
Overlays
Application⌄
Settings
Application⌄
The theme toggle rides at the end of the section rail at the top of the page: the theme is a class on the document, remembered in local storage, and a setting for the whole document belongs with the page's own navigation rather than inside one of its cards.
The notification tray below is the other setting worth trying: a plain list that slides in from the corner. Both are ordinary HTML and CSS with a little script on top, so nothing blocks the first paint.
Feedback & states
Application⌄
Alerts
Alerts carry a semantic color and an ARIA role.
Your changes were saved.
This action is irreversible.
Something went wrong. Please retry.
Progress, skeleton and spinner
The spinner is a CSS ring and the bars are a quiet placeholder.
Empty, error and loading shapes
Nothing here yet
An empty list is not an error.
A toast
Rendered by the server, queued by the script.
Why server-side rendering?
Overview⌄
Hygguile renders complete HTML on the server. The browser receives a finished document — headings, links, labels and controls included — and a little JavaScript enhances it afterwards.
"Old school" server rendering is not nostalgia. For the kinds of software most of us ship — content, forms, dashboards, admin panels — rendering on the server keeps the fast path fast and the simple path simple.
- First paint without the framework tax
- The HTML is ready to paint. There is no framework bundle to download, parse and execute before the first pixel, so pages feel immediate even on slow connections and modest hardware.
- No hydration, no double work
- Nothing is re-rendered on the client just to attach behaviour. The server has already produced the final tree, so there is no hydration step that can disagree with the markup.
- It works when JavaScript does not
- Links navigate and forms submit. Disclosure, tabs, the modal and tooltips keep working with scripting disabled; the theme toggle, carousel arrows and live notifications simply step in when they can.
- Accessible and discoverable by default
- Real headings, landmarks, labels and form controls are present in the first byte, so assistive technology and crawlers never wait on a client-side rendering race.
- One source of truth
- State lives in the database and in the URL, not in a second client-side store. There is no model to duplicate, no cache to invalidate, and no stale-UI bug class caused by the two drifting apart.
- Cacheable and cheap to serve
- A page is a pure function of its request, which makes responses easy to cache, compress and proxy. Payloads stay small: mostly text, styled by one stylesheet the browser already holds.
- A smaller, calmer stack
- No client router, no global store, no build step that has to agree with the server. The mental model is the one the web began with: request a URL, receive a document, follow a link.
Single-page applications earn their complexity when you genuinely need offline support or a deeply interactive workspace. Even then it is a spectrum rather than a binary: ship mostly-static HTML and add interactivity only where it repays its cost. That is exactly how Hygguile is built — a document first, with small islands of behaviour.
;; A page is a pure function of its data
(define (page request)
`((h1 "Posts")
(ul ,@(map render-post (posts-for request)))))
;; Render it once, on the server, and send text to the browser