---
title: "Shortwind"
description: "A token-efficient class layer for LLM-generated HTML. Collapses Tailwind utility chains into 1–3 token recipes, cutting class-string tokens by 50–80% with zero runtime cost."
slug: "shortwind"
tags: ["Tailwind", "LLM", "Tokens", "CLI"]
url: "https://github.com/raymond-UI/shortwind"
repo: "raymond-UI/shortwind"
embedUrl: "https://shortwind.dev"
---


LLMs spend 35–50% of their HTML output tokens on Tailwind class strings. Shortwind collapses common class clusters into `@`-prefixed recipes a model memorizes on the first turn, then expands them back to plain Tailwind before the browser sees anything.

```html
<article class="@card-elevated @stack-md">
  <header class="@row-between">
    <h3 class="@heading-md">Quarterly review</h3>
    <span class="@badge-success">On track</span>
  </header>
  <p class="@muted">3 of 5 milestones complete.</p>
  <div class="@row">
    <button class="@btn-primary">Open</button>
    <button class="@btn-ghost">Skip</button>
  </div>
</article>
```

Same rendered output, roughly half the tokens.

<Callout title="Status" tone="warning">
Beta. `@shortwind/cli` publishes under the `@beta` tag; the catalog and registry are live at [shortwind.dev](https://shortwind.dev), with bundler plugins and `upgrade` still landing.
</Callout>

## Measured impact

Benchmarked on representative components with the `cl100k_base` tokenizer:

| Metric | Shortwind | Expanded | Savings |
|--------|-----------|----------|---------|
| Class-string tokens | 125 | 658 | **81.0%** |
| Whole-file LLM tokens | 1,255 | 3,001 | **58.2%** |

Per-file savings range from 46.6% on layout-heavy markup to 77.9% on recipe-dense components.

## Installation

<ToolInstall cmd="npx @shortwind/cli@beta init" />

`init` detects your bundler (Vite, Next, Astro) and installs the matching adapter, copies the recipe catalog into `./recipes/` under your ownership, generates `skills/shortwind/SKILL.md` for agent discovery, and installs a pre-commit hook to keep that skill file in sync.

You never install all `@shortwind/*` packages, only one adapter plus the Tailwind integration.

## How it works

1. **The model writes shorthand** — `@`-prefixed recipes inside `class="..."`
2. **The expander resolves them** — each `@name` expands to its definition, nested references resolve transitively, `shortwind lint` rejects cycles
3. **Tailwind sees only utilities** — no runtime cost, no semantic change; purge, JIT, and dev servers behave normally

Two expansion paths share the same `@shortwind/core` parser and produce byte-identical output:

- **Build-time** — a Vite/Next/Astro plugin transforms source before Tailwind's content scan. Production ships zero `@` tokens and no runtime expander.
- **Runtime** — a standalone ~6KB `shortwind.dev/expand.js` walks the DOM before first paint. For HTML artifacts an LLM emits straight to a `.html` file.

## Recipe format

Recipes live in `.css` files, which buys comments, multi-line definitions, universal syntax highlighting, and free Tailwind IntelliSense hover via `classRegex`.

```css
/* recipes/card.css */
/* shortwind: card@0.4.2 sha:b0a1c3 — DO NOT EDIT THIS LINE */

/* Default content card. */
@recipe card {
  rounded-lg border border-zinc-200 bg-white p-4 shadow-sm
}

/* Raised card for primary content. Built on @card. */
@recipe card-elevated {
  @card rounded-xl p-6 hover:shadow-md transition-shadow
}

/* Interactive card with full hover + focus states baked in. */
@recipe card-interactive {
  @card hover:shadow-md hover:border-zinc-300
  focus-visible:ring-2 focus-visible:ring-zinc-900
  cursor-pointer transition-all
}
```

Recipes bake in canonical hover/focus/active/dark states, so the model picks `@card` or `@card-interactive` and never writes `hover:@card`. One-off states get appended as raw Tailwind, and `tailwind-merge` resolves conflicts last-position-wins.

## Naming convention

Names follow `@<family>[-<intent>][-<size>]` so models generalize instead of memorizing every combination.

| Token kind | Allowed values |
|------------|----------------|
| Size suffix | `xs`, `sm`, `md`, `lg`, `xl` |
| Intent suffix | `primary`, `secondary`, `ghost`, `danger`, `warning`, `success`, `info` |
| Order | Intent first, size last: `@btn-primary-lg`, never `@btn-lg-primary` |

Not every family uses both axes: `@stack-md` is size-only, `@badge-success` is intent-only, `@btn-primary-lg` is both.

There is deliberately no parameter syntax like `@btn(primary, lg)`. Parameters introduce grammar, and grammar is a new way for models to be wrong. Flat names with predictable suffixes give regularity at zero parsing cost.

## Default catalog

~19 families and ~150–200 recipes, shipped broad so models rarely need to reach for raw Tailwind.

| Family | Examples |
|--------|----------|
| `card` | `card`, `card-elevated`, `card-flat`, `card-interactive`, `card-header`, `card-body` |
| `button` | `btn-primary`, `btn-secondary`, `btn-ghost`, `btn-destructive`, `btn-icon`, `btn-lg` |
| `badge` | `badge`, `badge-success`, `badge-warning`, `badge-danger`, `badge-outline` |
| `layout` | `stack-xs`…`stack-lg`, `row`, `row-between`, `grid-2`, `grid-3`, `center`, `full` |
| `text` | `heading-xl`…`heading-sm`, `body`, `muted`, `label`, `caption`, `link` |
| `form` | `input`, `textarea`, `select`, `checkbox`, `field`, `field-error`, `fieldset` |
| `surface` | `surface`, `surface-muted`, `wrapper`, `wrapper-tight`, `divider-h`, `divider-v` |
| `feedback` | `alert`, `alert-success`, `alert-danger`, `callout`, `toast`, `banner` |
| `navigation` | `nav`, `nav-link`, `nav-link-active`, `breadcrumb`, `tab`, `tab-active` |
| `table` | `table`, `th`, `td`, `tr-hover`, `table-zebra` |
| `media` | `avatar`, `avatar-sm`, `thumb`, `aspect-square`, `aspect-video` |
| `dialog` | `dialog`, `dialog-overlay`, `dialog-content`, `dialog-header`, `dialog-footer` |
| `skeleton` | `skeleton`, `skeleton-text`, `skeleton-circle` |

Plus `list`, `code`, `empty`, `progress`, `tooltip`, and `icon`.

## CLI

```bash
shortwind init                          # interactive setup
shortwind init --preset=app             # starter | app | content | all | none
shortwind add card button badge layout  # install families
shortwind add card --as marketing-card  # rename on install to avoid collision
shortwind new marketing                 # scaffold a custom family
shortwind remove table                  # delete family + skills section
shortwind upgrade                       # walk families, show changelogs, prompt on touched files
shortwind upgrade --check               # CI gate: non-zero exit on drift
shortwind dev                           # watch ./recipes/, regenerate SKILL.md on save
shortwind ls                            # list installed families + recipes
shortwind lint --fix                    # validate usage, naming, cycles, conflicts
```

### Presets

| Preset | Families | Catalog size |
|--------|----------|--------------|
| `starter` | card, button, layout, text, form | ~25 recipes |
| `app` (default) | starter + badge, table, dialog, list, navigation, feedback, tooltip | ~65 recipes |
| `content` | starter + badge, code, list, media, empty | ~50 recipes |
| `all` | every family | ~100 recipes |

## Agent discovery

Shortwind generates `skills/shortwind/SKILL.md` in [skills.sh](https://www.skills.sh) format, a path every major harness (Claude Code, Cursor, Copilot, Aider, Continue) auto-discovers. That saves per-agent symlinks and file fan-out.

The file regenerates whenever `./recipes/` changes, through any of three sync surfaces: the `shortwind dev` watcher, the bundler plugin hooking your dev server's watcher, or the pre-commit hook that guarantees the committed copy is fresh.

Comments in your `.css` recipe files become the inline explanations in `SKILL.md`, so editing `recipes/card.css` updates what the model reads. The full skill file for the default catalog is ~150 lines, small enough for any system message.

## Lint rules

| Rule | Trigger | Severity |
|------|---------|----------|
| `recipe/unknown` | `@name` with no matching recipe | Error |
| `recipe/cycle` | Recipe references itself transitively | Error |
| `recipe/no-sibling-overlap` | Two recipes from one family on one element | Warning |
| `recipe/conflicting-intent` | Conflicting intent suffixes | Warning |
| `recipe/bad-suffix-order` | `@btn-lg-primary`, size before intent | Warning, auto-fixable |
| `recipe/dynamic-class` | Likely-computed name, e.g. `@${variant}` | Warning |
| `recipe/no-redundant-utility` | Raw utility already present in the recipe | Info |

Unknown recipes pass through unchanged rather than throwing, so a failure is visible at view-source and the rest of the document is unaffected.

## Upgrades

Every recipe file carries a fingerprint header:

```css
/* shortwind: card@0.4.2 sha:b0a1c3 — DO NOT EDIT THIS LINE */
```

`shortwind upgrade` compares the recorded sha against current content. Pristine files apply cleanly with no prompt; files you've edited show a three-way diff and require confirmation. Your edits are never silently clobbered. `recipes/.shortwind-lock.json` records `{ family: version }` and commits with your code, and `shortwind upgrade --check` is a read-only CI gate.

## Packages

| Package | Role | Install it? |
|---------|------|-------------|
| `@shortwind/cli` | All commands | Tooling, or run it with `npx` |
| `@shortwind/vite` / `next` / `astro` | Bundler plugin, transforms before Tailwind's scan | Pick one; `init` adds it |
| `@shortwind/tailwind` | Detects v3 vs v4, registers via the right API | `init` adds it |
| `@shortwind/core` | Parser and resolver; no Tailwind dependency | No, transitive |
| `@shortwind/runtime` | Browser/CDN expander, ~6KB with catalog inline | Only for the CDN path |

Tailwind v3 and v4 are both supported. The expander is version-agnostic; only the registration glue differs. On v4, `.css` recipe files sit naturally alongside `@theme` and `@source`.

## Registry

[shortwind.dev](https://shortwind.dev) hosts the catalog, docs, playground, registry, and CDN expander from a single Cloudflare Worker. Browse families visually, see expansions and example rendering, and copy the `npx` command to add one.

The registry origin is overridable. `shortwind.config.json` accepts `registry: "https://corp-internal.example.com"`, so enterprise mirrors and forks work without code changes.

<ToolRepo href="https://github.com/raymond-UI/shortwind" title="View on GitHub" />
