# I don't want to write HTML or fight global CSS, so I built a TypeScript DSL

> Source: <https://dev.to/kazuy/i-dont-want-to-write-html-or-fight-global-css-so-i-built-a-typescript-dsl-406d>
> Published: 2026-05-31 00:40:20+00:00

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
  # or npm install draft-ole
  # or yarn 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.

``` js
  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:

``` js
  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](https://github.com/object-zaseeta/DraftOle/issues) and [Discussions](https://github.com/object-zaseeta/DraftOle/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.
