Reactive HTML

Add application logic directly to your HTML page.

Markout makes HTML modular, reactive and isomorphic. Bootstrap, Tailwind, or CSS of your own — whatever your markup already is, it stays exactly that, and gains the ability to hold behavior.

No component framework above your markup.
A reactive HTML engine underneath it.

No Node? The VS Code extension needs none: open a folder containing a markout/ docroot and go.

examples/theming.html HTML
<html lang="en" :i=${0}>
<head :colors=${['#0d6efd', '#6f42c1', '#198754']}>
  <:import src="/npm/@markout-lang/bootstrap-kit/all.htm" />
  <bs-theme-auto />
  <style>
    .btn-primary { --bs-btn-bg: ${colors[i]}; }
  </style>
</head>
<body class="p-3 text-center">
  <button class="btn btn-primary"
          :count=${0}
          :on-click=${() => count++}>
    Clicked ${count} time${count !== 1 ? 's' : ''}
  </button>
  <button class="btn btn-sm btn-outline-secondary ms-2"
          :on-click=${() => i = (i + 1) % 3}>
    Change theme
  </button>
</body>
</html>
the result open the page

A reference implementation

What reactive HTML could be, carried out far enough to build real pages with. It is production-ready, but it exists to show the idea — judge it on whether the idea holds, not on the size of an ecosystem it never set out to have.

It extends HTML, rather than replacing it

Frameworks churn because they revise what they invented — classes then hooks, options then composition, stores then runes. :class-, :on- and :attr- are named after what the platform already has, and the platform does not change its mind.

Leaving is cheap

What you write is mostly HTML and JavaScript already, and markout build emits plain HTML, CSS and JS with nothing to run in production. Moving off Markout is closer to removing attributes than to rewriting an application.

Your CSS framework and your app framework are separate choices

Ant Design means React. Vuetify means Vue. PrimeNG means Angular. Pick a component library and you have picked a framework too — and from then on neither decision can be revisited without the other.

Those are separable concerns. A CSS framework is a markup convention; a web component library is a set of custom elements. Neither needs a framework at all — they need a way to pass values in, set non-string properties, and listen for events.

Markout around Bootstrap, Tailwind or Shoelace keeps both choices open: change how the page is put together without touching the components, or change the components without touching the logic. The rest of this page is that claim, carried out.

The same page, minus the mechanical parts

You can make ordinary Bootstrap markup interactive as it stands, or let Markout's own Bootstrap kit carry the parts that were only ever mechanical. These two files render exactly the same.

index-plain.html
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
        rel="stylesheet">
  <title>Northstar Studio</title>
</head>

<body>
  <nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom sticky-top">
    <div class="container py-2">
      <a class="navbar-brand fw-bold text-primary" href="#top">Northstar Studio</a>
      <button class="navbar-toggler" type="button"
              data-bs-toggle="collapse" data-bs-target="#main-navigation"
              aria-controls="main-navigation" aria-expanded="false"
              aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse" id="main-navigation">
        <ul class="navbar-nav ms-auto mb-2 mb-lg-0 align-items-lg-center">
          <li class="nav-item"><a class="nav-link" href="#services">Services</a></li>
          <li class="nav-item"><a class="nav-link" href="#work">Our work</a></li>
          <li class="nav-item"><a class="nav-link" href="#insights">Insights</a></li>
          <li class="nav-item ms-lg-3"><a class="btn btn-primary" href="#contact">Start a project</a></li>
        </ul>
      </div>
    </div>
  </nav>
index.html
<head>
  <:import src="/npm/@markout-lang/bootstrap-kit/all.htm" />
  <title>Northstar Studio</title>
</head>

<body>
  <bs-navbar ::items=${[
    { name: 'Services', link: '#services' },
    { name: 'Our work', link: '#work' },
    { name: 'Insights', link: '#insights' },
    { name: 'Start a project', link: '#contact', button: true },
  ]}>
    Northstar Studio
  </bs-navbar>

Everything below the navbar is untouched. You lift out what is boilerplate and leave your content alone — no rewriting the page into a template language.

The whole language is a handful of rules

Markout doesn't replace HTML with a component framework. It shows what HTML could look like if it were natively modular and reactive.

01

HTML is the syntax

Anything without a ${…} or a : is plain markup, and stays plain markup.

02

One expression syntax

${…} is plain JavaScript, in text, attributes and CSS alike. Whatever holds one is reactive.

03

One prefix for everything else

: names what HTML has no name for, always as :family-name:class-, :attr-, :on-, :for-.

04

Scopes nest lexically

A value is visible to every descendant, with no separate wiring. An expression resolves where it was written.

05

A component says what it takes

:: marks a component's interface — declared on the <:define>, passed at the usage. Everything else on that tag is a plain : and stays yours.

06

Adding is its own spelling

A plain class= replaces, the way every attribute does. class+= adds, class-= takes away, and style has the same pair — nothing merges behind your back.

No rule above has a convenient exception. A shortcut that saves a few characters at the call site but makes every future reader remember a special case isn't a simplification — it's deferred complexity.

The full syntax, on one page

If you're already reaching for Alpine or htmx

These are the tools an HTML page usually picks up when it needs behavior, so here is the honest comparison.

Alpine.js htmx Markout
Behavior written in HTML attributes Yes Yes Yes
What it needs to run A <script> tag A <script> tag Node to serve, or markout build to static files
Mistakes caught before the page loads No, silent at runtime Yes, with a file and a line
Content present in the served HTML No, x-cloak hides the gap Yes, the server wrote it Yes, served or prerendered
Same source renders on the server No, client only The server owns the HTML Yes
Reusable components in markup No — x-data reuses behavior; markup comes from the server Server-side partials <:define> + <:slot>
Non-string values into a custom element x-bind writes attributes; a property means reaching for $el :prop-name=${…}
Parametric CSS Inline styles, or CSS variables set inline Whatever the server renders ${…} inside <style>
Interaction without a server round-trip Yes No, by design Yes

And the honest costs.

Alpine is mature, and its ecosystem, community and documentation are far larger than Markout's. It also asks for strictly less to get started — one <script> tag, on any host, behind any backend — where Markout wants Node in the request path or markout build ./my-site ./dist.

htmx is solving a different problem — server-driven UI — and composes fine with either. Markout's claim is narrower: one source that is modular, reactive, and renders in both places.

Bootstrap Kit

Components that are just HTML fragments

The kit is a folder of .htm files. Each one takes a piece of Bootstrap markup and gives it a name.

The whole of Bootstrap's component set, with the wiring and the accessibility attributes written once instead of on every page that needs them.

Import the whole kit, or only the parts you need. Because they are ordinary fragments, you can open them, read them, change them, and build your own alongside — there is no component API to learn beyond the markup itself.

Every component is a bs- tag carrying Bootstrap's own concepts. Reach for one the way you'd reach for the markup it replaces — and when it does more than the plain version, the fragment that says so is a click away.

Every Bootstrap component, as a tag:

<bs-accordion> <bs-alert> <bs-badge> <bs-breadcrumb> <bs-button> <bs-button-group> <bs-card> <bs-carousel> <bs-close> <bs-collapse> <bs-dropdown> <bs-list-group> <bs-modal> <bs-navbar> <bs-offcanvas> <bs-pagination> <bs-placeholder> <bs-popover> <bs-progress> <bs-scrollspy> <bs-spinner> <bs-toast> <bs-tooltip>

Plus the form controls — <bs-input> is taken apart further down the page, and <bs-navbar> is the one doing the work in the before/after above.

…and the next one you write

Adding a component means writing a fragment and giving it a name — not registering a class or learning an extension API.

bootstrap-kit/parts/button.htm the whole component
<:define tag="bs-button:a"

         // parameters
         ::href=${null}
         ::variant=${null}
         ::outline=${null}
         ::size=${'md'}

         // private
         class="btn btn-${size}${variant ? ` btn-${variant}` : ''}"
         :attr-href=${href}
>
  <:slot />
</:define>

Then the page needs to do something

Bootstrap ships the styles for validation states and leaves you to toggle them yourself. That gap is usually filled by hand, with jQuery, or by adding Alpine.

Here it's filled once, inside a component — which is then just another tag.

bootstrap-kit/parts/input.htm the component
<:define tag="bs-input:div"
  class="mb-3"

  // parameters
  ::label=${null}
  ::type=${'text'}
  ::value=${''}
  ::placeholder=${''}
  ::required=${false}
  ::check=${(v) => true}
  ::message=${'Please check this value.'}

  // private
  :_empty=${value === '' || value == null}
  :_invalid=${_empty ? required : !check(value)}

  // read from outside: is there a value, and does it pass?
  :valid=${!_empty && check(value)}
>
  <label class="form-label">${label}</label>

  <input class="form-control"
    type=${type}
    value=${value}
    placeholder=${placeholder}
    :class-is-invalid=${_invalid}
    :on-input=${ev => value = ev.target.value}>

  <div class="invalid-feedback">${message}</div>
</:define>
examples/validation.html using it
<html lang="en">
<head>
  <:import src="/npm/@markout-lang/bootstrap-kit/all.htm" />
  <bs-theme-auto />
  <title>A form that validates itself</title>
</head>
<body class="p-4">
  <form>
    <bs-input :aka="email"
              ::label="Email address"
              ::type="email"
              ::placeholder="you@example.com"
              ::check=${v => v.includes('@')}
              ::message="That doesn't look like an email address." />

    <bs-input :aka="password"
              ::label="Password"
              ::type="password"
              ::check=${v => v.length >= 8}
              ::message="Use at least 8 characters." />

    <button class="btn btn-primary"
            :attr-disabled=${!(email.valid && password.valid)}>
      Create account
    </button>
  </form>
</body>
</html>
the result — type into it open the page

Still Bootstrap's classes

form-control, is-invalid and invalid-feedback are Bootstrap's, unchanged. All that's added is what decides when they apply.

Validity is derived, not set

:_invalid and :valid are expressions over ::value, so there is no code path that can forget to clear the error, or to re-enable the button.

A function is just a value

::check holds a function like any other value, and resolves in the scope it was written in — the caller's, not the component's.

Those aren't HTML attributes

Logic written in attributes usually means one line, no comments, and quoting by luck. That is the assumption Markout invites — and it is wrong.

A component, written as a declaration the shape
<:define tag="my-counter:div"

      // parameters

      ::start=${0}
      ::step=${1}

      // private

      :_count=${start}

      // read from outside

      :value=${_count}
      :bump=${() => {
        /*
         since properties are reactive,
         this assignment transparently updates all
         dependents of `_count`
        */
        _count += step;
      }}

>${_count}</:define>

<my-counter :aka="clicks" ::start=${5} ::step=${2} />
<button :on-click=${() => clicks.bump()}>Bump</button>
<p>Now at ${clicks.value}.</p>

A scope is a JavaScript object. What a tag declares are its properties and methods, and an expression's extent is found by parsing it — so HTML's rules stop at the ${.

What that adds up to is reactive JavaScript in an HTML-shaped syntax, with one assumption removed: that an object's properties are passive — that assigning to one tells nobody. Here everything reading it follows, which is why there's no useState and no dependency array to write. Nothing announces it, either: no signal(), no ref(). Markout's minimalism is exactly why it's worth pointing out.

Parsed, not guessed at

> inside an expression doesn't close the tag, a quote doesn't end the value, and strings, template literals and nested ${...} end where JavaScript says they do.

Grouped and commented

Attributes span lines, and // and /* ... */ between them are stripped at parse time. A component stops being a long line and becomes something closer to a class body.

So the logic can be real

The kits are written this way: bs-input above, and std-data in the standard kit, which holds a whole fetch lifecycle inline.

Showing one thing or another

The other half of behavior: not what an element does, but whether it is there at all.

a chain of branches
<bs-spinner :if=${loading} />

<bs-alert :else-if=${error}
          ::variant="danger">
  ${error}
</bs-alert>

<bs-table :else
          ::columns=${columns}
          ::rows=${rows} />

Nothing inside it runs

A branch that is not showing evaluates nothing, which is what makes ${user.name} safe to write in one. It is a guarantee rather than an optimisation — a guard that still evaluated its body would not be a guard.

The element is parked, not rebuilt

Hiding moves the markup into a <template> and showing moves it back, so a scroll position, a focused input or a playing video survives the round trip.

Truthiness, as JavaScript means it

:if asks the question JavaScript asks, so ${count} and ${name} mean what they look like. Where you want an item, if there is one — where 0 and '' are data — :for-data is the one that binds it.

A mode an element is in

Sometimes nothing should appear or disappear. The same card is simply being dragged now — and everything that means arrives together, and leaves together.

a panel that can be edited
<div class="panel">

  <:mode :if=${editing}
         :_draft=${text}
         :class-editing
         :attr-contenteditable=${true}>

    <button :on-click=${() => {
      text = _draft; editing = false;
    }}>Save</button>

  </:mode>

  <p>${text}</p>
</div>

The element never moves

A <:mode> has no element of its own and borrows the one above it. That is the difference from putting :if on the panel, which would take it out of the page and lose the caret, the scroll position and anything else the DOM was holding.

The state belongs to the mode

_draft is the edit's, not the panel's, so it is gone when the edit is. Without somewhere for it to live it goes on the panel and has to be cleared by hand — the bug everybody writes once.

And it hands everything back

Nothing is remembered. What an element's title is, is whatever the innermost live declaration says — so a mode giving one up is the panel's own expression being asked again, still live the whole time and simply not the one writing.

A listener that is not there

The other half is what a mode does not do. A pointermove handler declared in a drag mode is bound when the drag starts and unbound when it ends, rather than firing hundreds of times a second to decide it has nothing to do.

Tailwind

Tailwind needs no kit at all

A utility class is already the smallest thing Tailwind has, so there is no mechanical markup to lift out and nothing to wrap. The classes stay classes, and the stylesheet is built ahead of time and never regenerated.

What a kit can honestly carry here is the setup, and the setup is two meta tags and a <link> — with the stylesheet's URL a :const- token, so a page names its own Tailwind build at the import site rather than forking the file.

Which leaves Markout doing what it does everywhere else. The demo is a pricing page, and everything moving on it is one of three things: a palette that is a value, a class string chosen by a ternary, and one card written once.

The palette is a value

Tailwind compiles bg-brand-600 to var(--color-brand-600), so moving the variable retunes every utility reading it — no stylesheet regenerated, no class name touched.

A class is a string

Which is what a utility framework and a reactive language have in common: a class attribute is a string, and ${…} is how Markout computes one — here a ternary picking between two sets of utilities.

One card, three on the page

:for-each is the whole of the repetition, and it is the same attribute it is on a page with no CSS framework at all.

demos/tailwind/index.html HTML
<html :hue=${259}>

<style>
  :root {
    --color-brand-500: oklch(0.623 0.214 ${hue});
    --color-brand-600: oklch(0.546 0.245 ${hue});
  }
</style>

<button class="px-5 py-2 rounded-full
        ${yearly ? 'bg-brand-600' : 'text-slate-600'}">
  Yearly
</button>

<article :for-each=${plans} :for-key=${data.id}>
  <h2 class="text-lg font-semibold">${data.name}</h2>
</article>

And the one thing a scanner cannot see.

Tailwind finds utilities by reading files for candidate strings — raw text, not parsed HTML — so a literal inside ${…} is found as readily as one in a class attribute, both branches of a ternary included. What it cannot see is Markout's own toggle: :class-ring-2=${…} spells the utility in the attribute name, and class-ring-2 is not a utility.

So the compiler is asked rather than guessed at. markout build --class-manifest writes every class a page's toggles can apply into the page itself, as literal class names a scanner already knows how to read; --classes-only writes one file for the whole site instead and skips rendering, which makes it quick. Point your scanner at the result and the toggles are generated like everything else — resolved through <:import> and treeshaken, so a kit's toggles are included without your naming the kit, which a regex over your sources could do neither of.

The demo above is built that way: npm run build:tailwind runs the manifest build and then tailwindcss. And it is named for the page rather than for the vendor — a page declaring the class names it can wear is a fact about the page, so UnoCSS or Panda read the same file with no per-tool knowledge in the compiler.

Web components

Custom elements, with nothing above them

A custom element is already a component — the browser renders it with no help. What plain HTML can't do is hand it an array, flip a boolean attribute, or hear it say something back.

That is the whole reason a Shoelace or Web Awesome page ends up with a framework on top of it, and it is a lot of machinery to take on for three missing verbs. Markout has the three, spelled so that which one you meant is never inferred.

:prop-

Assigns the JS property, so an element can take an array or an object instead of the string an attribute would have flattened it into.

:attr-

Controls whether the attribute is there, which is the only question disabled, open and the rest of the boolean family are asking.

:on-

Listens for the event type verbatim, custom names included: sl-change is an event like click is, and needs no registration.

demos/shoelace/index.html HTML
<sl-select multiple
           :prop-value=${seasons}
           :on-sl-change=${e => seasons = e.target.value}>
  <sl-option :for-each=${allSeasons}
             value=${data}>${data}</sl-option>
</sl-select>

<sl-card :for-each=${inSeason} :for-key=${data.id}>
  <h3>${data.name}</h3>

  <sl-button :attr-disabled=${inBasket(data)}
             :on-click=${() => basket = [...basket, data]}>
    Add to basket
  </sl-button>
</sl-card>

Zero-cost design systems

A kit is a fragment importing other fragments. You can easily write your own which pulls in the Bootstrap kit, sets what your team decided, and adds whatever is specific to your app.

examples/app-kit.htm your kit
<lib :const-bsRadius="2rem"
  :const-appAccent="#6f42c1"
  :const-appAccentDark="#59359a">

  <:import src="/npm/@markout-lang/bootstrap-kit/all.htm" />

  <style>
    .btn-primary {
      --bs-btn-bg: ${appAccent};
      --bs-btn-border-color: ${appAccent};
      --bs-btn-disabled-bg: ${appAccent};
      --bs-btn-disabled-border-color: ${appAccent};
      --bs-btn-hover-bg: ${appAccentDark};
      --bs-btn-hover-border-color: ${appAccentDark};
      --bs-btn-active-bg: ${appAccentDark};
      --bs-btn-active-border-color: ${appAccentDark};
    }
  </style>

  <:define tag="app-email:div"
    // what the page reads to gate its own controls
    ::valid=${input.valid}>
    <bs-input :aka="input"
      ::label="Email address"
      ::type="email"
      ::placeholder="you@example.com"
      ::check=${v => v.includes('@')}
      ::message="That doesn't look like an email address." />
  </:define>

</lib>
examples/design-system.html every page
<html lang="en">
<head>
  <:import src="app-kit.htm" />
  <bs-theme-auto />
  <title>A page built on your own kit</title>
</head>
<body class="p-4">
  <app-email :aka="email" />
  <bs-button ::variant="primary"
             ::disabled=${!email.valid}>Save</bs-button>
</body>
</html>
the result open the page

Values reach the CSS

${…} works in a stylesheet exactly as it does in markup, so a value like appAccent is a design token without any special treatment: no build step, no preprocessor, no custom property indirection unless you want one.

Defaults, then overrides

bsRadius belongs to the Bootstrap kit, which declares its own default. Naming it again here replaces it for every page that imports your kit — and you never forked the component that reads it.

:const- means constant

A token that never changes is worked out once at compile time. And the day one does have to move — a light/dark switch — the page declares that same name plainly instead, and the kit reading it doesn't change a character.

Shared tokens, shared components, one place to change them — and nothing to install, compile, or keep in sync. The whole system is a file your team can read in a minute.

std Kit

The framework layer, written in the language

Data and routing are the two things a framework is usually built in. Here they are components, and nothing in the compiler knows what a fetch or a route is.

It ships with the compiler, so a page has it already — no install, and no import unless you would rather say it out loud.

Which is the claim the whole design rests on: if the system parts of a page can be written with the language, the language does not have to grow them.

A datasource std-data
<std-data :aka="people" ::url="/api/people" />

<li :for-each=${people.data}>${data.name}</li>
A router std-router
<std-router>
  <std-route ::page="index">…</std-route>
  <std-route ::page="about">…</std-route>
</std-router>
Isomorphic by design

The same page runs on the server and in the browser

The same scope and value model runs in both places, so server-side rendering comes for free.

No separate server template. No separate client application. No framework-specific hydration model to understand — it is the same page, evaluated twice.

A page that never becomes interactive ships no runtime at all.

index.html
one file, one scope tree
Server
renders HTML
Browser
makes it reactive

The Markout experience, in your editor

"Caught before the page loads" is worth little if you find out in a terminal. The VS Code extension puts the compiler where you are typing.

The compiler's own errors

On the right line, without saving — and for every page in the project, not only the ones you have open. A page that imports a broken fragment says so, and the fault is reported in the file it was written in.

It knows what is in scope

Completion of the names visible where you are, the tags every imported kit defines, and the parameters one of those tags takes. Go to definition on a name, on a custom tag — including one inside an installed kit — and on an <:import> path. Hover, rename and find-references reach as far as the name itself does, across pages and fragments alike.

It can format your files

Which an HTML formatter cannot: the > in ${a > b} ends no tag, and one that thinks it does rewrites the rest of your attributes into text. A page is indented like a page, a .htm fragment like the module it is.

And it installs your kits

A view of its own: the kits this project uses, each a checkbox. Tick one and it is fetched and pinned — no npm, no terminal, no toolchain. Preview and Build are beside them, and the preview runs on the copy of Node your editor is already running, so nothing looks for one on your PATH.

The compiler and the server are bundled, so they work on a project that has installed nothing — and with the view above, a project that installs nothing can still use kits, preview and build. It adds to VS Code's HTML support rather than replacing it: a page is still an html document, so Emmet, tag completion and every other HTML extension keep working as they did.

settings.json json
"[html]": { "editor.defaultFormatter": "markout.markout-vscode" }

Start with the Bootstrap Kit

Look inside a component. Change it. Extend it. Write your own. It's HTML — there is nothing else to open.

Install shell
npm install -g @markout-lang/cli @markout-lang/bootstrap-kit
Serve a directory shell
markout ./my-site
Work on it shell
markout ./my-site --dev
Ship it static shell
markout build ./my-site ./dist