17 KiB
Multi-Framework Support
How to consume
@whynot/designfrom 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.htmlwith no JS framework at all- An HTMX
hx-getfragment 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:
- Global stylesheets style them. Your
components.cssrules for.wn-btnapply to the button inside<wn-button>. No::part, no::slotted, no CSS variables-as-API. - 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. - Form participation works.
<wn-input>containing a real<input>participates in<form>submission naturally. No ElementInternals contortions. - 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'sconnectedCallback.
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. UseuseEffect+addEventListenerfor component-emitted events:React 19 supportsconst 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>;onWnDismissdirectly. - JSX typing. If you use TypeScript, add a
whynot.d.tsdeclaring the custom elements as JSX intrinsic elements.@whynot/designdoes 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
- 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 thenindex.jshas loaded. - Block on the module if it's truly critical. Remove
deferfrom the script tag for a smaller-than-50kb runtime — acceptable. Or usetype="module"(which defers by default but blocksDOMContentLoaded). - Use
noscriptfallbacks. 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/designfrom 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 thenameattribute. 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 atypes/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.