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.
<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>
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.
<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><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.
HTML is the syntax
Anything without a ${…} or a
: is plain markup, and stays
plain markup.
One expression syntax
${…} is plain JavaScript, in
text, attributes and CSS alike. Whatever holds one is reactive.
One prefix for everything else
: names what HTML has no name
for, always as :family-name —
:class-,
:attr-,
:on-,
:for-.
Scopes nest lexically
A value is visible to every descendant, with no separate wiring. An expression resolves where it was written.
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.
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 pageIf 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.
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.
Adding a component means writing a fragment and giving it a name — not registering a class or learning an extension API.
<: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.
<: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><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>
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.
<: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.
<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.
<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 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.
<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.
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.
<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.
<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>
<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>
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.
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.
<std-data :aka="people" ::url="/api/people" />
<li :for-each=${people.data}>${data.name}</li><std-router>
<std-route ::page="index">…</std-route>
<std-route ::page="about">…</std-route>
</std-router>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.
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.
"[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.
npm install -g @markout-lang/cli @markout-lang/bootstrap-kitmarkout ./my-sitemarkout ./my-site --devmarkout build ./my-site ./dist