Frameworks#
Morpheus is framework-agnostic: its components are standard custom elements that render as plain DOM nodes anywhere. Pass state as attributes, put slotted content between the tags, and subscribe to Morpheus custom events. The same contract works in React, Vue, Svelte, Solid, server-rendered HTML, or plain vanilla JavaScript.
The simplest way to use Morpheus is to load the bundle once and write the elements directly in your framework's templates, the same as any other HTML.
Morpheus components are designed to be driven by changes to the DOM in a declarative way, and hence don't provide an imperative API surface. Control them through attributes and observe state changes through events. For example:
- Turn a
<neo-switch>on by setting itscheckedattribute, and react to the user toggling it by listening for theneo-switch-changeevent. - Show a toast by rendering a
<neo-toast duration="4000">inside the<neo-toaster>; it animates in and auto-dismisses after 4 seconds. It emits aneo-toast-closeevent when the user clicks its close button.
Datastar (Signals)#
The following example utilizes Reactive Datastar signals drives the components straight from HTML attributes, with no build step and no component wrapper. The example below is an accent-color picker held in two local signals; the _ prefix keeps them client-only, never serialized to a backend. A data-on:* listener reads the kit's events and data-attr:* bindings push the values back, so the <neo-select>, the <neo-color-field>, and the readout stay in sync.
Accent color
<neo-card class="framework-demo-card">
<div data-neo-card-inner data-signals="{_color: '#3b82f6', _option: 'blue'}">
<neo-layout column gap="md" align-items="center">
<h3>Accent color</h3>
<neo-layout column gap="md" align-items="center">
<neo-select
aria-label="Named color"
placeholder="Pick a color"
data-attr:value="$_option"
data-on:neo-select-change="$_option = evt.detail.value; $_color = ({red:'#ef4444',green:'#22c55e',blue:'#3b82f6'})[evt.detail.value] ?? $_color"
>
<neo-option value="red">Red</neo-option>
<neo-option value="green">Green</neo-option>
<neo-option value="blue">Blue</neo-option>
<neo-option value="custom">Custom</neo-option>
</neo-select>
<neo-color-field
class="framework-demo-field"
aria-label="Custom color"
data-attr:value="$_color"
data-on:neo-color-field-input="$_color = evt.detail.value; $_option = ({'#ef4444':'red','#22c55e':'green','#3b82f6':'blue'})[evt.detail.value] ?? 'custom'"
></neo-color-field>
</neo-layout>
<neo-layout inline gap="sm" align-items="stretch" class="framework-demo-readout">
<span class="framework-demo-swatch" data-attr:style="'background: ' + $_color" aria-hidden="true"></span>
<neo-textinput
mask="aaaaaa"
prefix="#"
aria-label="Hex color"
data-attr:value="$_color"
data-on:neo-textinput-input="$_color = evt.detail.value; $_option = ({'#ef4444':'red','#22c55e':'green','#3b82f6':'blue'})[evt.detail.value] ?? 'custom'"
></neo-textinput>
</neo-layout>
</neo-layout>
</div>
</neo-card>
Datastar (server-driven)#
Morpheus targets server-driven UIs just as well. This is the same picker, but no display state lives in the browser: the <neo-select> and <neo-color-field> post their change, and the server resolves the named-vs-custom logic and morphs the whole picker back. The Server script tab holds the handler. This page fakes the backend with an in-browser simulator, but the contract (action out, element morph back) is exactly what a real Datastar endpoint emits.
Accent color
<div id="fw-accent" style="display:contents" data-signals="{fw_color: '#3b82f6', fw_option: 'blue', fw_src: 'option'}">
<neo-card class="framework-demo-card">
<div data-neo-card-inner>
<neo-layout column gap="md" align-items="center">
<h3>Accent color</h3>
<neo-layout column gap="md" align-items="center">
<neo-select
value="blue"
aria-label="Named color"
placeholder="Pick a color"
data-on:neo-select-change="$fw_option = evt.detail.value; $fw_src = 'option'; @post('/frameworks/accent/')"
>
<neo-option value="red">Red</neo-option>
<neo-option value="green">Green</neo-option>
<neo-option value="blue">Blue</neo-option>
<neo-option value="custom">Custom</neo-option>
</neo-select>
<neo-color-field
value="#3b82f6"
class="framework-demo-field"
aria-label="Custom color"
data-on:neo-color-field-change="$fw_color = evt.detail.value; $fw_src = 'color'; @post('/frameworks/accent/')"
></neo-color-field>
</neo-layout>
<neo-layout inline gap="sm" align-items="stretch" class="framework-demo-readout">
<span class="framework-demo-swatch" style="background: #3b82f6" aria-hidden="true"></span>
<neo-textinput
mask="aaaaaa"
prefix="#"
value="#3b82f6"
aria-label="Hex color"
data-on:neo-textinput-change="$fw_color = evt.detail.value; $fw_src = 'color'; @post('/frameworks/accent/')"
></neo-textinput>
</neo-layout>
</neo-layout>
</div>
</neo-card>
</div>
import sim from "/static/datasim.js";
const NAMED = { red: "#ef4444", green: "#22c55e", blue: "#3b82f6" };
function optionForColor(hex) {
const lower = String(hex).toLowerCase();
return Object.keys(NAMED).find((k) => NAMED[k] === lower) ?? "custom";
}
// Single source of truth for the picker markup. Every morph renders through here.
// The page's initial server render mirrors render("#3b82f6", "blue").
function render(color, option) {
return `<div id="fw-accent" style="display:contents" data-signals="{fw_color: '${color}', fw_option: '${option}', fw_src: 'option'}">
<neo-card class="framework-demo-card">
<div data-neo-card-inner>
<neo-layout column gap="md" align-items="center">
<h3>Accent color</h3>
<neo-layout column gap="md" align-items="center">
<neo-select
value="${option}"
aria-label="Named color"
placeholder="Pick a color"
data-on:neo-select-change="$fw_option = evt.detail.value; $fw_src = 'option'; @post('/frameworks/accent/')"
>
<neo-option value="red">Red</neo-option>
<neo-option value="green">Green</neo-option>
<neo-option value="blue">Blue</neo-option>
<neo-option value="custom">Custom</neo-option>
</neo-select>
<neo-color-field
value="${color}"
class="framework-demo-field"
aria-label="Custom color"
data-on:neo-color-field-change="$fw_color = evt.detail.value; $fw_src = 'color'; @post('/frameworks/accent/')"
></neo-color-field>
</neo-layout>
<neo-layout inline gap="sm" align-items="stretch" class="framework-demo-readout">
<span class="framework-demo-swatch" style="background: ${color}" aria-hidden="true"></span>
<neo-textinput
mask="aaaaaa"
prefix="#"
value="${color}"
aria-label="Hex color"
data-on:neo-textinput-change="$fw_color = evt.detail.value; $fw_src = 'color'; @post('/frameworks/accent/')"
></neo-textinput>
</neo-layout>
</neo-layout>
</div>
</neo-card>
</div>`;
}
sim.post("/frameworks/accent/", async (ctx, sse) => {
const s = ctx.signals || {};
let color = String(s.fw_color ?? "#3b82f6");
let option = String(s.fw_option ?? "blue");
if (s.fw_src === "color") {
// The field moved: its color is authoritative, the option follows.
option = optionForColor(color);
} else if (option in NAMED) {
// A named option drives the field; "custom" keeps the color as is.
color = NAMED[option];
}
sse.patchElements(render(color, option));
});
HTMX#
The HTMX implementation should be very similar to the server-driven Datastar example.
React#
The following example is powered by React 19.2.7 One piece of React state keeps the <neo-select>, the <neo-color-field>, and the hex <neo-textinput> in sync.
<link rel="stylesheet" href="/static/min/morpheus.css"> <link rel="stylesheet" href="/static/min/theme-default.css"> <script type="module" src="/static/min/bundle.js"></script> <div id="root"></div> <script type="module" src="/src/main.tsx"></script>
// DISCLAIMER: This code was generated by Claude Opus 4.8 and not reviewed
// It is very well possible that this implementation is rubbish. For me, it was
// only important that it works.
//TODO: review and improve if necessary.
import { useCallback, useState } from "react";
// Ref callback that wires a Morpheus custom event to the node it lands
// on. React 19 calls the returned cleanup on unmount, so no useEffect /
// useRef: the listener's lifetime is the element's lifetime.
function useNeoEventRef<T>(
name: string,
handle: (detail: T) => void,
) {
return useCallback(
(node: HTMLElement | null) => {
if (!node) return;
const listener = (event: Event) =>
handle((event as CustomEvent<T>).detail);
node.addEventListener(name, listener);
return () => node.removeEventListener(name, listener);
},
[name, handle],
);
}
// Named swatches the select offers. Any color the field lands on
// that isn't one of these resolves to "custom".
const NAMED: Record<string, string> = {
red: "#ef4444",
green: "#22c55e",
blue: "#3b82f6",
};
function optionForColor(hex: string) {
const lower = hex.toLowerCase();
const name = Object.keys(NAMED).find((key) => NAMED[key] === lower);
return name ?? "custom";
}
export function ColorPicker() {
const [color, setColor] = useState(NAMED.blue);
const [option, setOption] = useState("blue");
const pickOption = useCallback(({ value }: { value: string }) => {
setOption(value);
// Named options drive the field; "custom" keeps the color as is.
if (value in NAMED) setColor(NAMED[value]);
}, []);
const pickColor = useCallback(({ value }: { value: string }) => {
setColor(value);
setOption(optionForColor(value));
}, []);
const select = useNeoEventRef<{ value: string }>(
"neo-select-change",
pickOption,
);
const field = useNeoEventRef<{ value: string }>(
"neo-color-field-input",
pickColor,
);
// The hex input reports { value } too; same handler as the field.
const hex = useNeoEventRef<{ value: string }>(
"neo-textinput-input",
pickColor,
);
return (
<neo-card>
{/* data-neo-card-inner carries the card's padding. */}
<div data-neo-card-inner>
<neo-layout column gap="md" align-items="center">
<h3>Accent color</h3>
<neo-layout column gap="md" align-items="center">
<neo-select
ref={select}
value={option}
aria-label="Named color"
placeholder="Pick a color"
>
<neo-option value="red">Red</neo-option>
<neo-option value="green">Green</neo-option>
<neo-option value="blue">Blue</neo-option>
<neo-option value="custom">Custom</neo-option>
</neo-select>
<neo-color-field ref={field} value={color} aria-label="Custom color" />
</neo-layout>
<neo-layout
inline
gap="sm"
align-items="stretch"
className="framework-demo-readout"
>
<span className="framework-demo-swatch" style={{ background: color }} aria-hidden="true" />
<neo-textinput
ref={hex}
value={color}
mask="aaaaaa"
prefix="#"
aria-label="Hex color"
/>
</neo-layout>
</neo-layout>
</div>
</neo-card>
);
}import type {
DetailedHTMLProps,
HTMLAttributes,
} from "react";
type NeoElementProps = DetailedHTMLProps<
HTMLAttributes<HTMLElement>,
HTMLElement
>;
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"neo-card": NeoElementProps;
"neo-layout": NeoElementProps & {
row?: boolean;
column?: boolean;
inline?: boolean;
wrap?: boolean;
gap?: "none" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
"align-items"?: string;
"justify-content"?: string;
};
"neo-select": NeoElementProps & {
value?: string;
placeholder?: string;
};
"neo-option": NeoElementProps & {
value?: string;
};
"neo-color-field": NeoElementProps & {
value?: string;
hue?: number | string;
};
"neo-textinput": NeoElementProps & {
value?: string;
mask?: string;
prefix?: string;
};
}
}
}Alpine.js#
Alpine.js v3.x.x binds in the markup: x-data holds the color, the option, and the named-vs-custom logic; :value pushes that state onto each element and @event listeners feed the kit's events back. No build step and no component wrapper: the two-way binding falls out of the declarative bindings.
Accent color
<div
x-data="{
named: { red: '#ef4444', green: '#22c55e', blue: '#3b82f6' },
color: '#3b82f6',
option: 'blue',
optionForColor(hex) {
const lower = hex.toLowerCase();
return Object.keys(this.named).find((k) => this.named[k] === lower) ?? 'custom';
},
pickOption(value) {
this.option = value;
if (value in this.named) this.color = this.named[value];
},
pickColor(value) {
this.color = value;
this.option = this.optionForColor(value);
},
}"
>
<neo-card class="framework-demo-card">
<div data-neo-card-inner>
<neo-layout column gap="md" align-items="center">
<h3>Accent color</h3>
<neo-layout column gap="md" align-items="center">
<neo-select
aria-label="Named color"
placeholder="Pick a color"
:value="option"
@neo-select-change="pickOption($event.detail.value)"
>
<neo-option value="red">Red</neo-option>
<neo-option value="green">Green</neo-option>
<neo-option value="blue">Blue</neo-option>
<neo-option value="custom">Custom</neo-option>
</neo-select>
<neo-color-field
class="framework-demo-field"
aria-label="Custom color"
:value="color"
@neo-color-field-input="pickColor($event.detail.value)"
></neo-color-field>
</neo-layout>
<neo-layout inline gap="sm" align-items="stretch" class="framework-demo-readout">
<span class="framework-demo-swatch" :style="'background: ' + color" aria-hidden="true"></span>
<neo-textinput
mask="aaaaaa"
prefix="#"
aria-label="Hex color"
:value="color"
@neo-textinput-input="pickColor($event.detail.value)"
></neo-textinput>
</neo-layout>
</neo-layout>
</div>
</neo-card>
</div>