The Zero-JS, Tor-Friendly Terminal UI Framework
Torminal CSS is a lightweight, dependency-free CSS framework for building terminal-inspired interfaces that work cleanly in privacy-first environments such as Tor Browser. It uses native HTML and CSS only: no frontend JavaScript, no remote assets, no webfont downloads, and no build step.
Torminal is used as the UI foundation for DeadDrop, but it is designed to be reusable in other small PHP/static apps that need low-resource, keyboard-friendly, mobile-ready interfaces.
Live docs:
docs.htmlwalks every module below and previews it in place.index.htmlis the landing/showcase page. Both are single files with no<script>tag anywhere — open them directly from disk.
- Zero JavaScript: interactive patterns rely on native HTML or CSS-only mechanisms such as
:checked,:target, and<details>. - Tor-friendly: no external fonts, no remote CSS, no tracking dependencies, and a conservative default
monospacestack. - Low-resource: one plain CSS file, no bundler, no runtime framework.
- Readable on mobile: responsive containers, flex utilities, touch-friendly buttons, and stack-on-mobile helpers.
- Accessible by default: visible keyboard focus, reduced-motion support, and CSS-only toggles that stay screen-reader and keyboard reachable.
- Reusable: shared utility classes and modules replace page-level inline CSS.
These are not stylistic preferences. Each one removes a measurable fingerprinting, compositing, or accessibility cost, and each is enforced by a comment at the rule it applies to.
| Rule | Why |
|---|---|
No @font-face, no font stacks. --t-font is the bare keyword monospace. |
Naming fonts — even system ones — makes glyph rendering depend on what the visitor happens to have installed. That is a machine-identifying signal. A generic keyword renders the same everywhere. |
No backdrop-filter. |
Forces a GPU compositing layer, costs battery on low-end hardware, and buys nothing over a flat alpha wash. |
| No remote assets of any kind. | No CDN, no @import, no webfont, no analytics. Nothing that could beacon. |
No display: none on interactive inputs. |
Clipped radios/checkboxes stay in the tab order. display:none silently makes a JS-free widget keyboard-unusable. |
| Animations are neutralisable. | Everything collapses under prefers-reduced-motion: reduce. |
| No JS-essential behaviour. | If a feature cannot work without <script>, it is rebuilt on CSS primitives or left out — never faked. |
These exist in the clearnet parent project and were not ported, because they cannot survive the rules above:
- Live fuzzy-filter command palette — a jump menu of links replaces it.
- Kanban drag-and-drop — the board ships as static layout only.
- JS typewriter effect, TOTP live countdown, clipboard copy button,
setTimeout-driven splash dismissal. - Every
.active/.show/.is-openruntime toggle hook (they are unreachable without JS).
Drop torminal.css into your project and link it from the page header:
<link rel="stylesheet" href="assets/torminal.css">Enable the CRT overlay by adding .t-crt to the body:
<body class="t-crt">
<div class="t-container t-box-md mt-4">
<h1 class="t-glow">> HELLO_WORLD_</h1>
<a href="#" class="t-btn">[ EXECUTE ]</a>
</div>
</body>The CRT effect is optional. For a calmer UI, use a plain <body> without .t-crt.
A small utility layer, intentionally far smaller than Bootstrap:
- Layout:
.d-flex,.d-none,.flex-wrap,.flex-column,.justify-content-center/-between/-end,.align-items-center/-start,.gap-1….gap-4 - Responsive display:
.d-none-mobile,.d-block-mobile,.d-none-desktop,.d-block-desktop,.w-100-mobile,.text-center-mobile,.flex-column-mobile - Spacing:
.m-0,.mt-*,.mb-*,.my-*,.p-*,.px-*,.py-*(.mt-5/.mb-5included) - Sizing:
.w-25,.w-50,.w-75,.w-100,.w-auto,.h-100,.min-vh-100 - Display / position:
.d-block,.d-inline-block,.position-relative,.overflow-hidden,.overflow-auto - Auto margins:
.ms-auto,.me-auto,.mx-auto - Text:
.text-center/-right/-left,.text-muted,.text-success,.text-danger,.text-warning,.text-info,.text-white,.text-uppercase,.text-break,.fs-small,.fs-1/-2/-3,.font-bold,.fw-normal - Backgrounds:
.bg-base,.bg-surface,.bg-grid - Borders:
.t-border,.t-border-top,.t-border-bottom,.border-dashed,.border-danger,.border-warning,.border-success - Accessibility:
.t-sr-only,:focus-visible,prefers-reduced-motion
Spacing utilities are
!important, so a page can still override a utility with a more specific rule and a local stylesheet loaded after Torminal.
Two mechanisms, deliberately redundant so a theme never degrades into an unstyled fallback.
1. Attribute form — works in every engine. Put it on <html>:
<html data-terminal-theme="amber">Presets: green (default), amber, cyan, red. Each reassigns the core palette tokens.
2. CSS-only radio switcher — requires :has() (Firefox 121+, Tor Browser 14.x+). It is wrapped in @supports selector(:has(*)), so older engines ignore the whole block and keep the attribute or default palette instead of breaking:
<div class="t-theme-switch">
<label for="t-theme-green">green</label><input class="t-toggle-checkbox" type="radio" name="t-theme" id="t-theme-green" checked>
<label for="t-theme-amber">amber</label><input class="t-toggle-checkbox" type="radio" name="t-theme" id="t-theme-amber">
</div>The label must come before its input — the checked chip is highlighted with .t-theme-switch label:has(+ input:checked).
Coverage is total. A preset is seven tokens:
--bg-base --bg-surface --t-green --t-green-dim --t-accent-rgb --t-code-fg --t-white
--t-accent-rgb is the important one. Every translucent accent wash in the stylesheet — hover
glows, tinted panels, dashed borders, the dot grid, focus rings, box shadows — is written as
rgba(var(--t-accent-rgb), <alpha>) rather than a literal, so changing the theme recolours
100% of the surface instead of leaving green behind on a non-green theme. Legacy comma syntax
is deliberate: rgba(var(--x), 0.05) works in every engine with custom properties, whereas
color-mix() and the modern slash syntax do not.
The only fixed colours left are neutrals (#000, #fff, near-black overlays) and the semantic
red / yellow / cyan status colours, which are theme-independent by design.
If you add a preset, set --t-accent-rgb to match --t-green — a mismatch shows up
immediately as a green tint.
<a class="t-btn" href="#">[ TIMELINE ]</a>
<a class="t-btn ghost" href="#">[ SETTINGS ]</a>
<a class="t-btn info" href="#">[ RADAR ]</a>
<a class="t-btn warning" href="#">[ WARN ]</a>
<a class="t-btn danger" href="#">[ LOCK ]</a>
<button class="t-btn t-btn-sm">SMALL</button>
<button class="t-btn t-btn-block">FULL WIDTH</button>Variants: default (green), .danger, .warning, .info, .ghost, plus .t-btn-sm and .t-btn-block.
<label class="t-form-label" for="key">Master Key</label>
<input class="t-input" id="key" type="password" placeholder="Insert Master Key...">
<input class="t-input error" type="text" value="bad value">
<span class="t-feedback text-danger">[!] Rejected.</span>
<textarea class="t-textarea" placeholder="Type payload..."></textarea>
<select class="t-select">
<option>TTL: Forever</option>
</select>Validation states: .t-input.error, .t-input.success, with .t-feedback for the message.
Terminal-style checkboxes and radios are CSS-only:
<label class="t-checkbox-label">
<input type="checkbox" checked><span class="t-checkmark">Encrypt payload</span>
</label>
<label class="t-radio-label">
<input type="radio" name="ttl" checked><span class="t-radiomark">Forever</span>
</label><div class="t-card">
<div class="t-card-header">[ Broadcast Station ]</div>
<p>Terminal card content.</p>
</div>
<div class="t-card danger">
<div class="t-card-header">[ Channel Compromised ]</div>
</div>
<div class="t-alert danger">[!] Authentication failed.</div>
<div class="t-alert warning">[~] Relay degraded.</div>
<div class="t-alert info">[i] Tor circuit rebuilt.</div>
<div class="t-alert private">[#] Private channel.</div>
<span class="t-badge">online</span>
<span class="t-badge private">private</span>
<span class="t-badge muted">idle</span>Card variants: .danger, .warning. Badge variants: .danger, .warning, .info, .private, .muted. Alert variants: .warning, .danger, .info, .private.
<div class="t-feed">
<div class="t-post private">
<div class="t-post-header">
<span class="t-post-author">@alias</span>
<span class="t-post-meta">ID: abc123</span>
</div>
<div class="t-quote private">
<span class="t-quote-author">> Replying to @node:</span>
Previous signal preview...
</div>
<div class="t-post-content">Decrypted message content...</div>
<div class="t-post-actions">
<a class="t-btn t-btn-sm" href="#">[ REPLY ]</a>
</div>
</div>
</div>Post variants: .local, .local-node, .private, .inbox, .burner, .deleted, .tombstone. Legacy page CSS that uses .post-card / .media-attachment still resolves — those are shipped as backward-compatible aliases.
<div class="t-lock-panel private">
<h1 class="t-vault-title t-glow">[ CLASSIFIED VAULT ]</h1>
<div class="t-vault-subtitle">Authentication required.</div>
<form method="post">
<input class="t-input" type="password">
<button class="t-btn t-btn-block font-bold">[ UNLOCK ]</button>
</form>
</div>.t-kv accepts either semantic markup or the div form — both render identically:
<!-- semantic -->
<dl class="t-kv">
<dt>host</dt><dd>ddeadrop.onion</dd>
<dt>exit</dt><dd class="text-break">somerandomlongexitnodekey</dd>
</dl>
<!-- div aliases -->
<div class="t-kv">
<div class="t-kv-row"><div class="t-kv-key">host</div><div class="t-kv-value">ddeadrop.onion</div></div>
</div>Also available: .t-metric-grid / .t-metric / .t-metric-value / .t-metric-label, .t-fingerprint for long identifiers and onion URLs, .t-code / .t-code-inline / .t-kbd, and .t-copyable + .t-copy-btn for copy-button layout (there is no clipboard behaviour — that needs JS).
<span class="t-led-dot t-led-green"></span> <!-- also -yellow, -red -->
<div class="t-progress-bg"><div class="t-progress-bar" style="width:62%">62%</div></div>
<div class="t-skeleton"></div>
<span class="t-spinner"></span>
<div class="t-toast-container"><div class="t-toast">saved</div></div>
<span class="t-typewriter-cursor"></span>
<div class="t-timer-line-bg"><div class="t-timer-line" style="width:40%"></div></div>Progress and timer widths come from an inline style on a server-rendered value; no JS is involved in displaying a static number. Toast styling is provided, but triggering a toast is the host page's problem.
.t-app-shell (centred page wrapper), .t-toolbar + .t-toolbar-group, .t-statusline, .t-command (a $ … command-line block), .t-command-row + .t-command-prompt (an input row with a prompt), .t-breadcrumb, .t-shell + .t-shell-header/-title/-body (terminal window shell), .t-window, .t-pagination + .t-page-link / .t-page-current / .t-page-btn.
Hero / landing: .t-hero (add .compact for a shorter one), .t-hero-title, .t-hero-subtitle, .t-logo-frame, .t-logo-glow.
- Media:
.t-media-grid,.t-media-item,.t-video-item,.t-media-frame,.t-media-caption - Social:
.t-avatar/.t-avatar-lg,.t-spoiler,.t-feed-cardfamily (-main,-header,-author,-meta,-content,-actions,-btn) - Composer:
.t-editor-card,.t-editor-toolbar,.t-editor-btn,.t-editor-textarea - Prose:
.t-markdown(headings, blockquote,pre,code, lists, images) - Activity:
.t-activity-cardfamily,.t-metric-box/.t-metric-val/.t-metric-large/.t-metric-unit/.t-metric-lbl - Layout:
.t-boardfamily (static — no drag),.t-masonry,.t-thread+.t-thread-item - Chat:
.t-chat-container,.t-bubble,.t-bubble-them,.t-bubble-me,.t-bubble-meta - Bilingual text swap:
.t-bilingual(or-btn/-tab) with.t-lang-default/.t-lang-alt— swaps on:hover,:focusand:focus-within
Everything below works with no JavaScript. Each one lists the markup constraint that makes it work, because in plain CSS those constraints are not optional.
Both use the same primitive: a clipped radio immediately followed by its label, with the panels as later siblings of that radio.
<div class="t-tabs-wrapper">
<input class="t-tab-radio" id="tab1" name="tabs" type="radio" checked>
<label class="t-tab-label" for="tab1">Timeline</label>
<input class="t-tab-radio" id="tab2" name="tabs" type="radio">
<label class="t-tab-label" for="tab2">Inbox</label>
<div class="t-tab-content-css" id="content1">Timeline content...</div>
<div class="t-tab-content-css" id="content2">Inbox content...</div>
</div>.t-pane is the same component under the clearnet name, wired as #pane1…#pane10 ↔ #paneview1…#paneview10.
Two constraints, both load-bearing:
- the label must be the radio's adjacent next sibling — that is what styles the selected tab;
- the panel must be a later sibling of the radio — that is what reveals it.
Wrapping the inputs in a separate header <div> satisfies (1) but silently breaks (2), because #tab1:checked ~ #content1 can no longer match. If you want that nested shape anyway, it works on engines with :has() (Firefox 121+ / Tor Browser 14.x+); Torminal ships a second rule inside @supports selector(:has(*)) for it.
Wiring covers 10 tabs/panes, which is the practical ceiling for a CSS-only strip: plain CSS
cannot pair an arbitrary radio with the panel at the same index, so each slot needs its own
selector — there is no loop and no wildcard. Slots 6–10 work today; to go beyond, extend all
three selector lists in the stylesheet (the #tabN list, the #paneN list, and the @supports
:has() form).
The id-based wiring above has a real constraint: the ids are global, so only one strip per page
can use #tabN / #contentN. Any page needing a second strip would collide.
Add .t-tabs-stack and panels become <section> instead of <div>:
<div class="t-tabs-wrapper t-tabs-stack">
<input class="t-tab-radio" id="x1" name="x" type="radio" checked>
<label class="t-tab-label" for="x1">one</label>
<input class="t-tab-radio" id="x2" name="x" type="radio">
<label class="t-tab-label" for="x2">two</label>
<section class="t-tab-panel">panel one</section>
<section class="t-tab-panel">panel two</section>
</div>The k-th radio now pairs with the k-th panel positionally, so ids are irrelevant and strips no
longer collide — unlimited strips, 10 slots each. It works because the element types differ:
:nth-of-type counts <input> for the radios and <section> for the panels, so any number of
<div>/<label> siblings can sit between them without shifting either count. That is precisely
why the panels must be <section> — a <div> would be counted against other divs in the wrapper.
The labels also do not have to be adjacent to their radios in this mode. Both modes can coexist on one page.
<a href="#help" class="t-btn">[ OPEN HELP ]</a>
<div id="help" class="t-modal">
<div class="t-modal-content">
<a href="#" class="t-modal-close-btn">X</a>
<h2 class="t-card-header">[ Help ]</h2>
<p>No JavaScript required.</p>
</div>
</div><a href="#lightbox-1"><img src="thumb.jpg" alt=""></a>
<div class="t-lightbox" id="lightbox-1">
<a class="t-lightbox-backdrop" href="#_" aria-label="Close"></a>
<img src="full.jpg" alt="">
<a class="t-lightbox-close" href="#_" aria-label="Close">[X]</a>
</div>The absolutely-positioned backdrop anchor is what makes click-outside-to-close work without a script.
<div class="t-command-palette" id="cmd">
<a class="t-command-palette-backdrop" href="#_" aria-label="Close"></a>
<div class="t-command-palette-box">
<div class="t-card-header">jump to</div>
<ul class="t-command-palette-list">
<li><a class="t-command-palette-item" href="#">▸ home</a></li>
</ul>
</div>
</div>This is deliberately a navigation menu, not a search box. The live fuzzy filter in the clearnet parent is genuinely JS-essential, so it was dropped rather than faked.
<details class="t-accordion-native" open>
<summary>Section</summary>
<div class="t-accordion-body">Body text.</div>
</details><ul class="t-tree">
<li><details open><summary>nodes</summary>
<ul><li><a class="t-tree-file" href="#">alpha.onion</a></li></ul>
</details></li>
</ul>.t-dropdown+.t-dropdown-label+.t-dropdown-menu+.t-dropdown-item, driven by a.t-toggle-checkboxsibling..t-switch+.t-switch-slideraround an<input type="checkbox">..t-file-wrapper+.t-file-btn— a pure-CSS label overlay. The chosen filename cannot be echoed back, so the label text is static.
docs.html is built from these; reuse them for your own manual:
.t-docs-body, .t-sidebar + .t-sidebar-logo, .t-main-content, .t-docs-section (add .no-border to drop the rule), .t-docs-example (renders a PREVIEW tag), .t-docs-code, .t-docs-utility-list, .t-docs-center-demo, .t-demo-strip, .t-demo-half, .t-demo-width-70, .t-demo-min-h, .t-clickable-demo.
Unprefixed aliases (.docs-section, .docs-example, .docs-code, .sidebar-logo) are kept for drop-in compatibility; the .t-docs-* spelling is canonical. There is a @media print block that flattens the docs layout to black-on-white.
Torminal is designed to avoid unnecessary fingerprinting surface:
- Do not load remote fonts.
- Do not import third-party CSS.
- Do not add analytics snippets.
- Prefer native HTML controls over custom JavaScript widgets.
- Keep animation optional and honour
prefers-reduced-motion.
The .t-crt overlay is aesthetic. It can be disabled if readability or performance is more important. Note that prefers-reduced-motion: reduce already suppresses both the CRT overlay and .t-glow.
Torminal welcomes CSS-only components and utility improvements. The rule of thumb remains:
If it requires
<script>, it does not belong in Torminal core.
Keep it simple, local, reusable, and Tor-friendly. When a feature cannot survive without JS, prefer rebuilding it on :checked, :target or <details> — and if that is not possible, leave it out and say so in the docs rather than shipping a dead control.