Files
whynot-design/MultiFrameworkSupport.md
tegwick 80252baf53
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
version 0.2.0 replaces fromer version!
2026-05-25 19:32:22 +02:00

17 KiB

Multi-Framework Support

How to consume @whynot/design from React, Django, Vue, Svelte, or plain HTML — without forking, restyling, or re-implementing components per framework.


TL;DR

whynot-design ships Web Components (custom elements built with Lit) as the canonical component layer. They render to light DOM — meaning the global stylesheets style them — and work identically in any HTML context.

<wn-button variant="primary">Promote prototype</wn-button>

That tag is valid in:

  • A Django template
  • A React JSX file
  • A Vue Single-File Component
  • A Svelte component
  • index.html with no JS framework at all
  • An HTMX hx-get fragment response

You do not write a different component per framework. You write the same custom element. The framework decides how to pass props (variant="primary" in HTML, variant="primary" in JSX, :variant="…" in Vue).


Architecture recap

┌─────────────────────────────────────────────────────────────┐
│  Layer 3 — Framework adapters (optional)                    │
│  Django {% include %} partials, Vue/React typed wrappers    │
├─────────────────────────────────────────────────────────────┤
│  Layer 2 — Custom elements (canonical)                      │
│  <wn-button>, <wn-card>, <wn-modal>, <wn-prototype-card>…   │
│  Lit-based, light DOM, ~5kb gzipped runtime total           │
├─────────────────────────────────────────────────────────────┤
│  Layer 1 — Tokens + CSS                                     │
│  colors_and_type.css + components.css                       │
└─────────────────────────────────────────────────────────────┘

Most consumers need only Layer 1 + Layer 2. Layer 3 is sugar.


How light DOM rendering works

A Lit component normally renders into a shadow root — its own isolated DOM tree with style encapsulation. That's wrong for a design system: it would prevent the global CSS from styling components, force every component to ship its own copy of the rules, and break server-side rendering.

whynot-design components override the render root to be light DOM:

class WnButton extends LitElement {
  createRenderRoot() { return this; }  // ← light DOM
  render() { return html`<button class="wn-btn wn-btn--${this.variant}"><slot></slot></button>`; }
}

Consequences:

  1. Global stylesheets style them. Your components.css rules for .wn-btn apply to the button inside <wn-button>. No ::part, no ::slotted, no CSS variables-as-API.
  2. SSR is trivial. The server renders the same <wn-button><button class="wn-btn">…</button></wn-button> HTML it would render for any tag. The browser later "upgrades" the element to add behaviour, but the HTML is already there — no FOUC, no hydration mismatch.
  3. Form participation works. <wn-input> containing a real <input> participates in <form> submission naturally. No ElementInternals contortions.
  4. HTMX swaps just work. When HTMX replaces a fragment of the page with new HTML from the server, any <wn-*> elements in the new fragment are upgraded automatically by the browser's connectedCallback.

The trade-off you give up is style isolation. For a design system, that's the right trade — the system wants its CSS to apply everywhere.


Per-framework setup

Plain HTML

The minimum viable setup. No build step.

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="/whynot/styles/colors_and_type.css">
  <link rel="stylesheet" href="/whynot/styles/components.css">
  <script type="module" src="/whynot/index.js"></script>
</head>
<body>
  <wn-page-header eyebrow="whynot · closed beta">
    <span slot="title">Concierge prototype triage</span>
    <span slot="actions"><wn-button variant="primary">Request invite</wn-button></span>
  </wn-page-header>
</body>
</html>

Vendor the three files (colors_and_type.css, components.css, index.js) into your static directory. That's the whole integration.


Django (server-rendered templates + HTMX)

This is the canonical non-React case and worth covering in detail.

1. Install

# In the Django app's repo.
pnpm add git+ssh://git@gitea.example.com/whynot/whynot-design.git#v0.2.0

# Or vendor without Node tooling — copy three files:
mkdir -p myapp/static/whynot
cp node_modules/@whynot/design/src/styles/colors_and_type.css myapp/static/whynot/
cp node_modules/@whynot/design/src/styles/components.css      myapp/static/whynot/
cp node_modules/@whynot/design/dist/index.bundle.js           myapp/static/whynot/

A small Makefile or package.json script makes this idempotent — re-run after every dependency bump:

sync-whynot:
	cp node_modules/@whynot/design/src/styles/*.css myapp/static/whynot/
	cp node_modules/@whynot/design/dist/*.js        myapp/static/whynot/

2. Wire it into your base template

{# templates/base.html #}
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
  <link rel="stylesheet" href="{% static 'whynot/colors_and_type.css' %}">
  <link rel="stylesheet" href="{% static 'whynot/components.css' %}">
  <script type="module" src="{% static 'whynot/index.js' %}" defer></script>
</head>
<body>
  {% block content %}{% endblock %}
</body>
</html>

That's it. From now on, any Django template can use <wn-*> elements with no further setup.

3. Use components in templates

{# templates/prototypes/detail.html #}
{% extends "base.html" %}

{% block content %}
<wn-top-nav active="prototypes"></wn-top-nav>

<main class="wn-main">
  <wn-breadcrumb>
    <a href="{% url 'prototypes' %}">Prototypes</a>
    <span>{{ prototype.id }}</span>
  </wn-breadcrumb>

  <wn-page-header eyebrow="{{ prototype.id }} · Prototype">
    <span slot="title">{{ prototype.pitch }}</span>
    <span slot="actions">
      <wn-button variant="secondary" icon="archive">Park</wn-button>
      <wn-button variant="primary" icon="arrow-right">
        Promote → {{ prototype.target }}
      </wn-button>
    </span>
  </wn-page-header>

  <wn-pipeline active-idx="{{ prototype.stage_idx }}"></wn-pipeline>

  <wn-field-row label="Learning question">
    {{ prototype.learning_question }}
  </wn-field-row>
  <wn-field-row label="Smallest useful test">
    {{ prototype.smallest_test }}
  </wn-field-row>
  <wn-field-row label="Risks">
    {{ prototype.risks }}
  </wn-field-row>
</main>
{% endblock %}

No {% include %} files needed for this to work. The components are real HTML; Django renders them like any other tag.

4. HTMX fragment swaps

When HTMX swaps a fragment into the page, the new HTML contains <wn-*> elements. The browser upgrades them automatically — no extra wiring.

{# views.py #}
def signal_create(request):
    if request.method == "POST":
        sig = Signal.objects.create(…)
        return render(request, "signals/_row.html", {"signal": sig})  # fragment
    return render(request, "signals/_form.html")

{# templates/signals/_row.html — the fragment HTMX swaps in #}
<wn-table-row>
  <wn-table-cell><code>{{ signal.id }}</code></wn-table-cell>
  <wn-table-cell><wn-stage-dot level="{{ signal.level }}"></wn-stage-dot></wn-table-cell>
  <wn-table-cell>{{ signal.what }}</wn-table-cell>
</wn-table-row>
{# Where HTMX swaps it in #}
<form hx-post="{% url 'signal-create' %}" hx-target="#signals tbody" hx-swap="afterbegin">
</form>

The new row's <wn-stage-dot> is upgraded on insertion. No htmx:afterSettle listener needed.

5. Django partials (optional Layer 3)

If a particular component pattern recurs and the raw HTML is verbose, ship a {% include %} partial:

{# adapters/django/templates/whynot/_prototype_card.html — vendored in your project #}
<wn-prototype-card id="{{ p.id }}" signal="{{ p.signal }}" stage-label="{{ p.stage_label }}">
  <span slot="pitch">{{ p.pitch }}</span>
  <span slot="learning">{{ p.learning }}</span>
  <span slot="test">{{ p.test }}</span>
  <span slot="target">→ {{ p.target }}</span>
</wn-prototype-card>

Then:

{% include "whynot/_prototype_card.html" with p=prototype %}

whynot-design ships a starter set of these in adapters/django/templates/whynot/. Copy them into your Django app's templates dir; don't add adapters/django/templates/ to TEMPLATES.DIRS directly — that would couple your INSTALLED_APPS to the design system's repo layout. Treat the partials as starting templates you can customise per-app.

6. Form participation

<wn-input>, <wn-textarea>, <wn-select> each wrap a real native input. Django's form rendering works as expected:

<form method="post">
  {% csrf_token %}
  <wn-field-row label="Prototype name">
    <wn-input name="name" value="{{ form.name.value|default:'' }}" required></wn-input>
  </wn-field-row>
  <wn-button variant="primary" type="submit">Save</wn-button>
</form>

The name attribute reaches the native input inside; the form submits normally.


React (Next.js, Vite, CRA, Remix)

React supports custom elements natively since v19; in v18 the support is mostly fine for the common cases (attributes-not-properties, no event handlers via props). The whynot components are designed against the v18 baseline.

1. Install

pnpm add @whynot/design

2. Wire it into your app entry

// app/layout.jsx (Next.js) or src/main.jsx (Vite)
import "@whynot/design/styles/colors_and_type.css";
import "@whynot/design/styles/components.css";
import "@whynot/design";              // registers all custom elements

3. Use components in JSX

export default function PrototypeDetailPage({ prototype }) {
  return (
    <main>
      <wn-page-header eyebrow={`${prototype.id} · Prototype`}>
        <span slot="title">{prototype.pitch}</span>
        <span slot="actions">
          <wn-button variant="primary" onClick={() => promote(prototype)}>
            Promote
          </wn-button>
        </span>
      </wn-page-header>

      <wn-field-row label="Learning question">
        {prototype.learningQuestion}
      </wn-field-row>
    </main>
  );
}

4. React-specific notes

  • Attributes vs properties. React passes string-typed props as attributes, which is what whynot components expect for all public APIs (variant, signal, active-idx). Boolean attributes work too (<wn-tag active>).
  • Events. React 18 doesn't bind onMyEvent-style props to custom-element events. Use useEffect + addEventListener for component-emitted events:
    const ref = useRef();
    useEffect(() => {
      const el = ref.current;
      el?.addEventListener("wn-dismiss", handleDismiss);
      return () => el?.removeEventListener("wn-dismiss", handleDismiss);
    }, []);
    return <wn-toast ref={ref}>Saved.</wn-toast>;
    
    React 19 supports onWnDismiss directly.
  • JSX typing. If you use TypeScript, add a whynot.d.ts declaring the custom elements as JSX intrinsic elements. @whynot/design does not ship this yet (deferred — see CHANGELOG).

5. Typed React wrappers (Layer 3, optional)

If your team prefers <WnButton variant="primary"> over <wn-button variant="primary">, one-line wrappers are trivial:

// myapp/lib/wn.jsx
export const WnButton = (props) => <wn-button {...props} />;
export const WnCard   = (props) => <wn-card   {...props} />;

Whether whynot-design ships these officially is on the deferred list — most React teams find them unnecessary once the IDE handles the custom-element completion.


Vue, Svelte, SolidJS

All work out of the box; custom elements are platform features, not framework features.

<!-- Vue 3 -->
<template>
  <wn-button variant="primary" @click="promote">Promote</wn-button>
</template>
<!-- Svelte 5 -->
<wn-button variant="primary" on:click={promote}>Promote</wn-button>

Vue may warn about unknown elements unless you configure compilerOptions.isCustomElement to recognise wn--prefixed tags. One-line vite plugin config.


Server-side rendering specifics

When SSR matters

For Django (or any server-rendered framework), <wn-button> reaches the browser as inert markup. It's upgraded — gains interactive behaviour — when the browser parses index.js and runs customElements.define(). Between those two moments, the user may see:

  • The button — fully styled by components.css, looking correct.
  • Click behaviour — depends on the component. Pure-visual components (Eyebrow, Card, Tag) have no behaviour to gain. Interactive components (Modal, Toast, Select) need the upgrade.

Strategies for behaviour-during-load

  1. Defer behaviour-required interaction. A <wn-modal> that's initially closed doesn't care about the upgrade window. Only when the user clicks "Open" does the upgrade matter — and by then index.js has loaded.
  2. Block on the module if it's truly critical. Remove defer from the script tag for a smaller-than-50kb runtime — acceptable. Or use type="module" (which defers by default but blocks DOMContentLoaded).
  3. Use noscript fallbacks. Components like <wn-toast> already work as inert static HTML if JS never loads. Add <noscript> siblings for behaviour-only flourishes (e.g. dismiss buttons).

Declarative Shadow DOM

Not used by whynot-design. Light-DOM rendering makes Declarative Shadow DOM unnecessary — the markup the server emits is the markup the browser shows.


Visual regression in consumer repos

Both the design-system repo and consuming repos run their own visual-regression suites against the same components. If the design system bumps a token value and examples/showcase/ passes (because the regression baselines were updated), but the consumer's app screenshots fail, the consumer's CI catches the regression first. That's the point of the dual-CI setup.

In Django/Playwright:

// consumer-repo/tests/visual/dashboard.spec.mjs
test("prototypes dashboard", async ({ page }) => {
  await page.goto("/prototypes/");
  await page.waitForSelector("wn-prototype-card");
  await expect(page).toHaveScreenshot("dashboard.png");
});

When @whynot/design ships 0.3.1 with a thicker hover bar, this test fails on the consumer side. Reviewer accepts the change, updates the snapshot, merges.


What you don't have to worry about

  • Framework version drift. Web Components are platform tech. A v0.3.0 component works in Lit 3, Lit 4, and post-Lit if Lit is ever replaced.
  • Multiple copies of the runtime. Lit deduplicates at the module level. Importing @whynot/design from 12 consumer pages still loads one copy.
  • Bundler edge cases. Vite, Next, Webpack 5, Rollup, esbuild all handle Lit and customElements.define() correctly out of the box.
  • A11y. Components emit semantic HTML (<button>, <input>, <dialog>) and inherit native accessibility. Where extra ARIA is needed (e.g. <wn-modal> traps focus, <wn-tag> doesn't claim a role), it's documented inline.

Open questions / known gaps

  • Native <form> participation for <wn-select> — currently fine because the inner native <select> carries the name attribute. ElementInternals-based participation may be added for richer custom selects later.
  • Server-side declarative shadow DOM — not needed today; light DOM covers all known consumers. Re-evaluate if a consumer ever needs strict CSS isolation per component.
  • TypeScript types for JSX — deferred. Type declarations for the custom elements (JSX.IntrinsicElements['wn-button']) will ship in a types/ folder once the first TS consumer asks.
  • Vue/Svelte adapter folders — none ship today. Add when a real consumer appears.

Summary

You're using… Setup Use
Plain HTML <link> + <script type="module"> <wn-button>
Django Vendor 3 files into static/whynot/, link from base.html <wn-button> in any template
HTMX Same as Django Fragments containing <wn-*> upgrade on insertion
React import "@whynot/design" once <wn-button> in JSX
Vue / Svelte / Solid Same as React (one-line isCustomElement config in Vue) <wn-button> in templates

One canonical implementation. No re-implementation per framework. No framework lock-in.

whynot-design is the implementation surface — not a UI kit. The discipline that keeps it framework-agnostic is the discipline that lets it survive technology shifts.