Skip to content

RFC: Novel Approaches to Streaming HTML for v0.3.0 #90

Description

@justrach

Deep Dive: How Tailwind CSS Came to Be + What We Can Learn

After experimenting with some prototypes (mercss.zig, streaming_css.zig), here are my thoughts on streaming + CSS evolution:

The Tailwind Success Story

Problem Tailwind solved:

  1. CSS specificity wars (!important hell)
  2. Naming fatigue (.btn-wrapper-inner-left)
  3. Giant CSS files (10KB+ of unused styles)
  4. Context switching (HTML → CSS file → back)

Their solution:

  • Utility-first: flex pt-4 text-center bg-blue-500
  • Atomic classes: Each does one thing
  • Composability: Classes compose like LEGO
  • Build-time: JIT compiler generates only used classes
  • Constraints: Design tokens (colors, spacing) enforced

Why it took off:

  • No naming = move faster
  • Small bundles (purged automatically)
  • Design system by default
  • "Just write HTML"

Where Tailwind Has Pain Points

  1. Runtime build pipeline: PostCSS → JIT compiler → PurgeCSS
  2. Scanning: Must scan all source files to find classes
  3. Config drift: tailwind.config.js separate from code
  4. Dynamic values: w-[123px] is runtime-evaluated
  5. No type safety: Wrong color name = runtime error

The Zig/merjs Opportunity

What Zig's comptime gives us that Tailwind can't do:

1. Compile-Time CSS Generation

// merjs knows ALL components at compile time
// No scanning needed!
const button = mer.css(.{
    .base = .{
        .padding = .{.x = .md, .y = .sm},
        .background = .primary,  // Type-safe design token
    },
    .states = .{
        .hover = .{ .background = .primary_light },
    },
});

Generates at comptime:

.mcss-a7f3e { padding: 8px 16px; background: #3b82f6; }
.mcss-a7f3e:hover { background: #60a5fa; }

No build step. No purging. No scanning. Only used styles exist.

2. Streaming-Aware CSS

Current problem:

  • HTML streams first → browser renders with no styles
  • CSS arrives later → flash of unstyled content

Solution:

// CSS streams WITH the component
return mer.html(
    h.div(.{ .class = styles.card }, .{
        // Critical CSS sent in shell
        h.header(.{}, "Card Title"),
        
        // Async component brings its own CSS
        h.Suspense(.{
            .fallback = h.spinner(),
            .css = .lazy,  // Only load if component renders
        }, asyncData()),
    })
);

CSS arrives in chunks:

<style>/* critical: layout, header */</style>
<div class="card">
  <header>Card Title</header>
  
  <style>/* component: spinner animation */</style>
  <div class="spinner">...</div>
  
  <!-- Later, when data arrives -->
  <style>/* component: table styles */</style>
  <table>...</table>
</div>

3. Type-Safe Design Tokens

// Define design system in Zig
const Theme = struct {
    colors = .{
        .primary = "#3b82f6",
        .danger = "#ef4444",
    },
    spacing = .{ .sm = 4, .md = 8, .lg = 16 },
};

// Compile-time error if wrong token
.button = .{
    .background = .primary,  // ✓ OK
    .padding = .xl,         // ✓ OK
    .color = .unknown,      // ✗ Compile error!
};

4. Layout-Aware CSS

// merjs knows the DOM structure at compile time
const Layout = struct {
    header: .{ .height = 64 },
    sidebar: .{ .width = 240 },
    main: .{ .fill_remaining = true },
};

// Generates optimal CSS without media queries
// merjs knows exact layout, generates grid/flex accordingly

5. State Machine Styling

const Button = mer.css.stateful(.{
    .states = .{ .idle, .hover, .loading, .disabled },
    
    .transitions = .{
        .{ .from = .idle, .to = .hover, .duration = 200 },
        .{ .from = .idle, .to = .loading, .duration = 0 },
    },
    
    .styles = .{
        .idle = .{ .opacity = 1 },
        .loading = .{ .opacity = 0.5, .cursor = .wait },
    },
});

Generates CSS + minimal JS for state transitions. No hydration needed for styling!


The Streaming Vision

Current SSR:

Server: [HTML chunk 1] [HTML chunk 2] [HTML chunk 3]
Client: [render] [FOUC!] [hydrate] [interactive]
        ↑ CSS arrives separately

merjs Streaming 2.0:

Server: [shell+CSS] [component1+CSS] [component2+CSS] [hydration]
Client: [render] [render styled] [render styled] [interactive]
        ↑ Never unstyled!

Key innovation: CSS is part of the component chunk, not a separate resource.


Experiment Prototypes

I pushed two experiment files:

  • src/mercss.zig - Compile-time atomic CSS
  • src/streaming_css.zig - CSS that streams with components

Both are rough sketches showing the direction.


Questions for Discussion

  1. Atomic vs semantic: Do we want Tailwind-style flex pt-4 or semantic .card .button?
  2. Inline vs extracted: CSS inline per component or extract to <style> blocks?
  3. Critical CSS: How to determine what's "critical" vs "lazy"?
  4. State management: Should CSS handle states (hover, loading) or JS?
  5. Tailwind compat: Interop with existing Tailwind classes or clean break?

Wild Ideas to Consider

  1. Content-aware styles: CSS that adapts based on content length, image sizes, etc.
  2. Network-aware streaming: Send simpler CSS on slow connections, richer on fast
  3. Animation co-streaming: CSS animations that sync with data arrival
  4. Theme streaming: Dark/light mode CSS streams conditionally
  5. Layout transitions: Animate between SSR layout and hydrated layout

My take: The combination of Zig's comptime + streaming SSR gives us a unique opportunity to solve CSS in a way no JS framework can. We can go beyond Tailwind's utility-first to "compiler-first" - where the CSS is as optimized as the machine code.

Thoughts? Which directions feel worth exploring?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions