I got tired of writing HTML and chasing global CSS rules. I had a hunch: what if you could write a page the same way you write an app β same declarative tree, same modifier chains, scoped style per node? I spent a year quietly testing the bet on my own side projects.
It... seems okay? I've open-sourced it as ** DraftOle** (
page()
writes plain static HTML + scoped CSS β zero runtime JavaScript shipped.app()
adds reactive state()
and event handlers β TypeScript arrow functions get serialized into a minimal runtime at build time.
pnpm add draft-ole
This is the 0.9.0 pre-1.0 release. The API surface is essentially settled and 1.0 is the next tag, but I'm intentionally holding back the 1.0 promise until I hear from real users. If you try it and it feels great or terrible, please tell me β both signals are useful.
(Yes, AI can generate HTML/CSS now. I'm not making a claim about how DraftOle compares β that's a separate experiment I haven't run. This article is just about what I built and why.)
Honestly? I just don't want to write HTML or global CSS anymore #
Let me be candid about the motivation. It's not a refined "type safety extends to the leaves" pitch. It's two embarrassingly small frustrations I kept hitting on every side project.
I'm building logic in TypeScript β typed values, typed functions, typed data flow β and then at the last mile I have to drop into stringly-typed HTML. Attribute names are strings. Class names are strings. Five levels of nesting and I can't tell which element carries which style anymore. The logical layer is type-safe, and then the presentation layer reverts to "paste these strings together carefully." That mismatch grates every time.
CSS-in-JS, CSS Modules, Tailwind β pick your weapon, eventually you write a rule against body
or *
, and now your scoping assumptions are gone. For a one-page LP I'd spend more time chasing "where is this style coming from" in DevTools than writing the page itself. After enough projects you just sigh and accept it.
When I build apps in React or SwiftUI, none of this bothers me. You drop a Text
inside a VStack
, you call .padding()
, the style attaches to that node. Structure and style coexist in a tree and stay local to it.
What if I could write a page the same way? Would the HTML problem and the CSS problem both just dissolve?
That was the bet. I wrote a private View DSL for a year, dogfooded it on my own landing pages and small apps, and eventually thought "you know, this might actually be okay to release." So I cleaned it up and pushed draft-ole@0.9.0
.
A pleasant side effect of basing the whole thing on a typed view tree: HTML attribute typos become compile errors. The TypeScript checker reaches all the way to your <div>
. That wasn't the primary goal β it's just what falls out when you build a strongly-typed view model.
import { page, Section, VStack, Text, Heading } from 'draft-ole';
const hero = Section(
VStack({ spacing: 16 },
Heading(1, 'Hello, DraftOle!')
.font({ size: '2.75rem', weight: '800' })
.foregroundStyle('#0f172a'),
Text('A TypeScript-native View DSL.')
.font({ size: '1.125rem' })
.foregroundStyle('#475569'),
)
.padding(48)
.frame({ maxWidth: 720 }),
)
.background('#f8f9ff');
const doc = page(hero, { lang: 'en', title: 'Hello' });
doc.export('./dist'); // writes index.html + style.css
Run with node --experimental-strip-types build.ts
(or tsx
, or ts-node
). The output is plain HTML and scoped CSS. Zero runtime JavaScript ships to the browser.
The modifier-chain shape is borrowed from SwiftUI's ViewModifier:
// SwiftUI
VStack(spacing: 16) {
Text("Hello")
.font(.title)
.foregroundStyle(.primary)
}
.padding(48)
// DraftOle
VStack({ spacing: 16 },
Text('Hello')
.font({ size: '2rem', weight: '800' })
.foregroundStyle('#0f172a'),
).padding(48);
When you need state and event handlers, app()
is the entry instead of page()
. Same primitives, same modifiers:
import { app, el, vstack, hstack } from 'draft-ole';
const doc = app({ title: 'Counter', lang: 'en' });
const count = doc.state(0);
const display = el.span()
.text(count.map((n) => String(n)))
.font({ size: '4rem', weight: '700', family: 'monospace' });
const incBtn = el.button({ type: 'button' }, '+1')
.on('click', () => count.set(count.get() + 1))
.padding('10px 24px')
.background('#6d28d9')
.foregroundStyle('#ffffff')
.cornerRadius('8px');
const view = vstack({ spacing: 20 }, display, incBtn).padding(48);
doc.exportTo('./dist', view); // writes index.html + style.css + script.js
doc.state<number>(0)
returns a reactive value. .on('click', () => count.set(count.get() + 1))
accepts a TypeScript arrow function. At build time, the function body is extracted from the AST, serialized into runtime JavaScript, and embedded into script.js
. The state subscription engine is a few hundred lines and ships with the output β that's the only runtime DraftOle adds when you use app()
.
The arrow function can only capture variables that go through the state API (.get / .set / .map / .update / .field / .each
). Capturing anything else (a closed-over variable, an outer-scope helper) is a compile-time error β the serializer needs a closed surface to work with.
examples/interactive/
ships with working demos of Counter, Todo, Form, Shopping Cart, Card Gallery, and Priority Tasks. The Todo app is around 100 lines.
What DraftOle does not do #
I'll leave you to draw comparisons against whatever you're currently using. What I can describe is the scope I have not implemented:
page()
and app()
return single pages. Multi-page wiring is your code.app()
mounts on load. There is no SSR + hydration two-step.node
.If any of these are required for your use case, DraftOle is not the right tool today, and that is fine.
How the internals work (a little) #
For the curious:
PairType
/ SelfClosingType
/ TextType
. A tree walker emits HTML strings deterministically.<head><style>...</style></head>
. No global selector wars..on(event, fn)
calls at the AST level and serializes them into runtime JavaScript. Anything that captures non-state-API closures is a compile-time error.package.json#dependencies
is empty. Everything in node_modules/draft-ole/
is DraftOle's own code.If reading source code as a documentation form appeals to you, src/transformer/
has handler-serializer.ts
, each-state-rewriter.ts
, and inline-recovery.ts
β they're some of the more interesting parts.
Try it in 60 seconds #
mkdir try-draftole && cd try-draftole
pnpm init -y
pnpm add draft-ole
cat > hello.ts <<'EOF'
import { page, Section, Text } from 'draft-ole';
const doc = page(
Section(Text('Hello from DraftOle.').padding(48)).background('#f0f4ff'),
{ lang: 'en', title: 'Hi' },
);
doc.export('./out');
EOF
node --experimental-strip-types hello.ts
open out/index.html
That's it. One TypeScript file, one node command, plain static HTML on disk.
0.9.0 is a feedback window. The API surface is essentially settled and 1.0 is the next tag, but I'm intentionally holding back the 1.0 promise until I hear from real users. Things I most want feedback on:
Card
, Grid
, Image
variants, form controls.)GitHub Issues and Discussions are both open. "I tried it and it doesn't fit my use case" is a valuable signal too β I want to know where the seams are.
If you give it a try, even a one-liner reaction in the comments or on social means a lot at this stage.