diff --git a/packages/anywidget/package.json b/packages/anywidget/package.json
index 895a2241..70dcb777 100644
--- a/packages/anywidget/package.json
+++ b/packages/anywidget/package.json
@@ -24,6 +24,7 @@
 		"@anywidget/types": "workspace:~",
 		"@anywidget/vite": "workspace:~",
 		"@jupyter-widgets/base": "^2 || ^3 || ^4 || ^5 || ^6",
+		"@lumino/widgets": "^2.3.1",
 		"solid-js": "^1.8.14"
 	},
 	"devDependencies": {
diff --git a/packages/anywidget/src/model.js b/packages/anywidget/src/model.js
new file mode 100644
index 00000000..d39e3ad3
--- /dev/null
+++ b/packages/anywidget/src/model.js
@@ -0,0 +1,381 @@
+import * as utils from "./util.js";
+
+/**
+ * @template {Record<string, unknown>} T
+ * @typedef {import('./view.js').View<T>} View
+ */
+
+/** @template {Record<string, unknown>} T */
+export class Model {
+	/** @type {import("./types.js").Comm=} */
+	comm;
+	/** @type {Omit<import("./types.js").ModelOptions, "comm">} */
+	#options;
+	/** @type {T} */
+	#state;
+	/** @type {Set<string>} */
+	#need_sync = new Set();
+	/** @type {Record<string, import("./types.js").FieldSerializer<any, any>>} */
+	#field_serializers;
+	/** @type {EventTarget} */
+	#events = new EventTarget();
+	/** @type {Map<any, { [evt_name: string]: Map<() => void, (event: Event) => void> }>} */
+	#listeners = new Map();
+
+	// NOTE: (from Jupyter Team): keep track of the msg id for each attr for updates
+	// we send out so that we can ignore old messages that we send in
+	// order to avoid 'drunken' sliders going back and forward
+	/** @type {Map<string, string>} */
+	#expected_echo_msg_ids = new Map();
+
+	#closed = false;
+
+	// NOTE: Required for the WidgetManager to know when the model is ready
+	/** @type {Promise<void>} */
+	state_change;
+
+	/** @type {Record<string, Promise<View<T>>>} */
+	views = {};
+
+	/**
+	 * @param {T} state
+	 * @param {import("./types.js").ModelOptions} options
+	 */
+	constructor(state, options) {
+		this.#state = state;
+		this.#options = options;
+		this.#field_serializers = {
+			layout: {
+				/** @param {string} layout */
+				serialize(layout) {
+					return JSON.parse(JSON.stringify(layout));
+				},
+				/**
+				 * @param {string} layout
+				 * @param {import("./types.js").WidgetManager} widget_manager
+				 */
+				deserialize(layout, widget_manager) {
+					return widget_manager.get_model(layout.slice("IPY_MODEL_".length));
+				},
+			},
+		};
+		this.comm = options.comm;
+		this.comm?.on_msg(this.#handle_comm_msg.bind(this));
+		this.comm?.on_close(this.#handle_comm_close.bind(this));
+		this.state_change = this.#deserialize(state).then((de) => {
+			this.#state = de;
+		});
+	}
+
+	get widget_manager() {
+		return this.#options.widget_manager;
+	}
+
+	/** @param {boolean} update */
+	set comm_live(update) {
+		// NOTE: JupyterLab seems to try to set this. The only sensible behavior I can think of
+		// is to set the comm to undefined if the update is false, and do nothing otherwise.
+		if (update === false) {
+			this.comm = undefined;
+		}
+	}
+
+	get comm_live() {
+		return !!this.comm;
+	}
+
+	get #msg_buffer() {
+		return {};
+	}
+
+	/**
+	 * Deserialize the model state.
+	 *
+	 * Required by any WidgetManager but we want to decode the initial
+	 * state of the model ourselves.
+	 *
+	 * @template T
+	 * @param state {T}
+	 * @returns {Promise<T>}
+	 */
+	static async _deserialize_state(state) {
+		return state;
+	}
+
+	/**
+	 * Serialize the model state.
+	 * @template {Partial<T>} T
+	 * @param {T} ser
+	 * @returns {Promise<T>}
+	 */
+	async #deserialize(ser) {
+		/** @type {any} */
+		let state = {};
+		for (let key in ser) {
+			let serializer = this.#field_serializers[key];
+			if (!serializer) {
+				state[key] = ser[key];
+				continue;
+			}
+			state[key] = await serializer.deserialize(
+				ser[key],
+				this.#options.widget_manager,
+			);
+		}
+		return state;
+	}
+
+	/**
+	 * Deserialize the model state.
+	 * @template {Partial<T>} T
+	 * @param {T} de
+	 * @returns {Promise<T>}
+	 */
+	async serialize(de) {
+		/** @type {any} */
+		let state = {};
+		for (let key in de) {
+			let serializer = this.#field_serializers[key];
+			if (!serializer) {
+				state[key] = structuredClone(de[key]);
+				continue;
+			}
+			state[key] = await serializer.serialize(de[key]);
+		}
+		return state;
+	}
+
+	/**
+	 * Handle when a comm message is received.
+	 * @param {import("./types.js").CommMessage} msg - the comm message.
+	 */
+	async #handle_comm_msg(msg) {
+		if (!this.comm) {
+			return;
+		}
+		if (utils.is_update_msg(msg)) {
+			await this.#handle_update(msg);
+			return;
+		}
+		if (utils.is_custom_msg(msg)) {
+			this.#emit("msg:custom", [msg.content.data.content, msg.buffers]);
+			return;
+		}
+		throw new Error(`unhandled comm message: ${JSON.stringify(msg)}`);
+	}
+
+	/**
+	 * @param {import("./types.js").UpdateMessage | import("./types.js").EchoUpdateMessage} msg
+	 */
+	async #handle_update(msg) {
+		let state = msg.content.data.state;
+		utils.put_buffers(state, msg.content.data.buffer_paths, msg.buffers);
+		if (utils.is_echo_update_msg(msg)) {
+			this.#handle_echo_update(state, msg.parent_header.msg_id);
+		}
+		// @ts-expect-error - we don't validate this
+		let deserialized = await this.#deserialize(state);
+		this.set_state(deserialized);
+	}
+
+	/**
+	 * @param {Record<string, unknown>} state
+	 * @param {string} msg_id
+	 */
+	#handle_echo_update(state, msg_id) {
+		// we may have echos coming from other clients, we only care about
+		// dropping echos for which we expected a reply
+		for (let name of Object.keys(state)) {
+			if (this.#expected_echo_msg_ids.has(name)) {
+				continue;
+			}
+			let stale = this.#expected_echo_msg_ids.get(name) !== msg_id;
+			if (stale) {
+				delete state[name];
+				continue;
+			}
+			// we got our echo confirmation, so stop looking for it
+			this.#expected_echo_msg_ids.delete(name);
+			// Start accepting echo updates unless we plan to send out a new state soon
+			if (this.#msg_buffer?.hasOwnProperty(name)) {
+				delete state[name];
+			}
+		}
+	}
+
+	/**
+	 * @param {string} name
+	 * @param {unknown} [value]
+	 */
+	#emit(name, value) {
+		this.#events.dispatchEvent(new CustomEvent(name, { detail: value }));
+	}
+
+	/**
+	 * Close model
+	 *
+	 * @param comm_closed - true if the comm is already being closed. If false, the comm will be closed.
+	 * @returns - a promise that is fulfilled when all the associated views have been removed.
+	 */
+	async #handle_comm_close() {
+		this.trigger("comm:close");
+		if (this.#closed) {
+			return;
+		}
+		this.#closed = true;
+		this.comm?.close();
+		this.comm = undefined;
+		for await (let view of Object.values(this.views)) {
+			view.remove();
+		}
+		this.trigger("destroy");
+	}
+
+	/**
+	 * @template {keyof T} K
+	 * @param {K} key
+	 * @returns {T[K]}
+	 */
+	get(key) {
+		return this.#state[key];
+	}
+
+	/**
+	 * @template {keyof T & string} K
+	 * @param {K} key
+	 * @param {T[K]} value
+	 */
+	set(key, value) {
+		this.#state[key] = value;
+		this.#emit(`change:${key}`);
+		this.#emit("change");
+		this.#need_sync.add(key);
+	}
+
+	async save_changes() {
+		if (!this.comm) return;
+		/** @type {Partial<T>} */
+		let to_send = {};
+		for (let key of this.#need_sync) {
+			// @ts-expect-error - we know this is a valid key
+			to_send[key] = this.#state[key];
+		}
+		let serialized = await this.serialize(to_send);
+		this.#need_sync.clear();
+		let { state, buffer_paths, buffers } = utils.extract_buffers(serialized);
+		this.comm.send(
+			{ method: "update", state, buffer_paths },
+			undefined,
+			{},
+			buffers,
+		);
+	}
+
+	/**
+	 * @overload
+	 * @param {string} event
+	 * @param {() => void} callback
+	 * @param {unknown} [scope]
+	 * @returns {void}
+	 */
+	/**
+	 * @overload
+	 * @param {"msg:custom"} event
+	 * @param {(content: unknown, buffers: ArrayBuffer[]) => void} callback
+	 * @param {unknown} scope
+	 * @returns {void}
+	 */
+	/**
+	 * @param {string} event
+	 * @param {(...args: any[]) => void} callback
+	 * @param {unknown} [scope]
+	 */
+	on(event, callback, scope = this) {
+		/** @type {(event?: unknown) => void} */
+		let handler;
+		if (event === "msg:custom") {
+			// @ts-expect-error - we know this is a valid handler
+			handler = (/** @type {CustomEvent} */ event) => callback(...event.detail);
+		} else {
+			handler = () => callback();
+		}
+		let scope_listeners = this.#listeners.get(scope) ?? {};
+		this.#listeners.set(scope, scope_listeners);
+		scope_listeners[event] = scope_listeners[event] ?? new Map();
+		scope_listeners[event].set(callback, handler);
+		this.#events.addEventListener(event, handler);
+	}
+
+	/**
+	 * @param {Partial<T>} state
+	 */
+	set_state(state) {
+		for (let key in state) {
+			// @ts-expect-error - we know this is a valid key
+			this.#state[key] = state[key];
+			this.#emit(`change:${key}`);
+		}
+		this.#emit("change");
+	}
+
+	get_state() {
+		return this.#state;
+	}
+
+	/**
+	 * @param {string | null} [event]
+	 * @param {null | (() => void)} [callback]
+	 * @param {unknown} [scope]
+	 */
+	off(event, callback, scope) {
+		for (let [s, scope_listeners] of this.#listeners.entries()) {
+			if (scope && scope !== s) {
+				continue;
+			}
+			for (let [e, listeners] of Object.entries(scope_listeners)) {
+				if (event && event !== e) {
+					continue;
+				}
+				for (let [cb, handler] of listeners.entries()) {
+					if (callback && callback !== cb) {
+						continue;
+					}
+					this.#events.removeEventListener(e, handler);
+					listeners.delete(cb);
+				}
+			}
+		}
+	}
+
+	/**
+	 * Send a custom msg over the comm.
+	 * @param {import("./types.js").JSONValue} content - The content of the message.
+	 * @param {unknown} [callbacks] - The callbacks for the message.
+	 * @param {ArrayBuffer[]} [buffers] - An array of ArrayBuffers to send as part of the message.
+	 */
+	send(content, callbacks, buffers) {
+		if (!this.comm) return;
+		this.comm.send({ method: "custom", content }, callbacks, {}, buffers);
+	}
+
+	/** @param {string} event */
+	trigger(event) {
+		utils.assert(
+			event === "destroy" || event === "comm:close",
+			"[anywidget] Only 'destroy' or 'comm:close' event is supported `Model.trigger`",
+		);
+		this.#emit(event);
+	}
+
+	/**
+	 * @param {string} event
+	 * @param {() => void} callback
+	 */
+	once(event, callback) {
+		let handler = () => {
+			callback();
+			this.off(event, handler);
+		};
+		this.on(event, handler);
+	}
+}
diff --git a/packages/anywidget/src/runtime.js b/packages/anywidget/src/runtime.js
new file mode 100644
index 00000000..dc5354a2
--- /dev/null
+++ b/packages/anywidget/src/runtime.js
@@ -0,0 +1,115 @@
+import * as solid from "solid-js";
+import * as util from "./util.js";
+
+/**
+ * This is a trick so that we can cleanup event listeners added
+ * by the user-defined function.
+ */
+let INITIALIZE_MARKER = Symbol("anywidget.initialize");
+
+export class Runtime {
+	/** @type {() => void} */
+	#disposer = () => {};
+	/** @type {Set<() => void>} */
+	#view_disposers = new Set();
+	/** @type {import('solid-js').Resource<util.Result<import("./types.js").AnyWidget & { url: string }>>} */
+	// @ts-expect-error - Set synchronously in constructor.
+	#widget_result;
+
+	/** @param {import("./model.js").Model<{ _esm: string, _css?: string, _anywidget_id: string }>} model */
+	constructor(model) {
+		this.#disposer = solid.createRoot((dispose) => {
+			let [css, set_css] = solid.createSignal(model.get("_css"));
+			model.on("change:_css", () => {
+				let id = model.get("_anywidget_id");
+				console.debug(`[anywidget] css hot updated: ${id}`);
+				set_css(model.get("_css"));
+			});
+			solid.createEffect(() => {
+				let id = model.get("_anywidget_id");
+				util.load_css(css(), id);
+			});
+
+			/** @type {import("solid-js").Signal<string>} */
+			let [esm, setEsm] = solid.createSignal(model.get("_esm"));
+			model.on("change:_esm", async () => {
+				let id = model.get("_anywidget_id");
+				console.debug(`[anywidget] esm hot updated: ${id}`);
+				setEsm(model.get("_esm"));
+			});
+			/** @type {void | (() => import("vitest").Awaitable<void>)} */
+			let cleanup;
+			this.#widget_result = solid.createResource(esm, async (update) => {
+				await util.safe_cleanup(cleanup, "initialize");
+				try {
+					await model.state_change;
+					let widget = await util.load_widget(update);
+					cleanup = await widget.initialize?.({
+						model: util.model_proxy(model, INITIALIZE_MARKER),
+					});
+					return util.ok(widget);
+				} catch (e) {
+					return util.error(e);
+				}
+			})[0];
+			return () => {
+				cleanup?.();
+				model.off("change:_css");
+				model.off("change:_esm");
+				dispose();
+			};
+		});
+	}
+
+	/**
+	 * @param {import("./view.js").View<any>} view
+	 * @returns {Promise<() => void>}
+	 */
+	async create_view(view) {
+		let model = view.model;
+		let disposer = solid.createRoot((dispose) => {
+			/** @type {void | (() => import("vitest").Awaitable<void>)} */
+			let cleanup;
+			let resource =
+				solid.createResource(this.#widget_result, async (widget_result) => {
+					cleanup?.();
+					// Clear all previous event listeners from this hook.
+					model.off(null, null, view);
+					util.empty(view.el);
+					if (widget_result.state === "error") {
+						util.throw_anywidget_error(widget_result.error);
+					}
+					let widget = widget_result.data;
+					try {
+						cleanup = await widget.render?.({
+							model: util.model_proxy(model, view),
+							el: view.el,
+						});
+					} catch (e) {
+						util.throw_anywidget_error(e);
+					}
+				})[0];
+			solid.createEffect(() => {
+				if (resource.error) {
+					// TODO: Show error in the view?
+				}
+			});
+			return () => {
+				dispose();
+				cleanup?.();
+			};
+		});
+		// Have the runtime keep track but allow the view to dispose itself.
+		this.#view_disposers.add(disposer);
+		return () => {
+			let deleted = this.#view_disposers.delete(disposer);
+			if (deleted) disposer();
+		};
+	}
+
+	dispose() {
+		this.#view_disposers.forEach((dispose) => dispose());
+		this.#view_disposers.clear();
+		this.#disposer();
+	}
+}
diff --git a/packages/anywidget/src/types.ts b/packages/anywidget/src/types.ts
new file mode 100644
index 00000000..03e1aac4
--- /dev/null
+++ b/packages/anywidget/src/types.ts
@@ -0,0 +1,106 @@
+export type JSONValue =
+	| string
+	| number
+	| boolean
+	| { [x: string]: JSONValue }
+	| Array<JSONValue>;
+
+export interface Comm {
+	/** Comm id */
+	comm_id: string;
+	/** Target name */
+	target_name: string;
+	/**
+	 * Sends a message to the sibling comm in the backend
+	 * @param  data
+	 * @param  callbacks
+	 * @param  metadata
+	 * @param  buffers
+	 * @return message id
+	 */
+	send: (
+		data: JSONValue,
+		callbacks?: unknown,
+		metadata?: Record<string, unknown>,
+		buffers?: ArrayBuffer[],
+	) => string;
+	/**
+	 * Closes the sibling comm in the backend
+	 * @param  data
+	 * @param  callbacks
+	 * @param  metadata
+	 * @param  buffers
+	 * @return msg id
+	 */
+	close(
+		data?: JSONValue,
+		callbacks?: unknown,
+		metadata?: Record<string, unknown>,
+		buffers?: ArrayBuffer[] | ArrayBufferView[],
+	): string;
+	/**
+	 * Register a message handler
+	 * @param  callback, which is given a message
+	 */
+	on_msg: (callback: (msg: CommMessage) => void) => void;
+	/**
+	 * Register a handler for when the comm is closed by the backend
+	 * @param  callback, which is given a message
+	 */
+	on_close: (callback: () => void) => void;
+}
+
+export type CommMessage = UpdateMessage | EchoUpdateMessage | CustomMessage;
+export type UpdateMessage = {
+	parent_header?: { msg_id: string };
+	buffers?: ReadonlyArray<ArrayBuffer | DataView>;
+	content: {
+		data: {
+			method: "update";
+			state: Record<string, unknown>;
+			buffer_paths?: ReadonlyArray<ReadonlyArray<string | number>>;
+		};
+	};
+};
+export type EchoUpdateMessage = {
+	parent_header: { msg_id: string };
+	buffers?: ReadonlyArray<ArrayBuffer | DataView>;
+	content: {
+		data: {
+			method: "echo_update";
+			state: Record<string, unknown>;
+			buffer_paths?: ReadonlyArray<ReadonlyArray<string | number>>;
+		};
+	};
+};
+export type CustomMessage = {
+	buffers?: ReadonlyArray<ArrayBuffer | DataView>;
+	content: {
+		data: {
+			method: "custom";
+			content: unknown;
+		};
+	};
+};
+
+export type WidgetManager = { get_model: (model_id: string) => any };
+export type FieldSerializer<A, B> = {
+	serialize: (value: A) => B | Promise<B>;
+	deserialize: (value: B, widget_manager: WidgetManager) => A | Promise<A>;
+};
+
+export type ModelOptions = {
+	model_id: string;
+	comm?: Comm;
+	widget_manager: WidgetManager;
+};
+
+export type AnyWidget = {
+	initialize: import("@anywidget/types").Initialize;
+	render: import("@anywidget/types").Render;
+};
+
+export type AnyWidgetModule = {
+	render?: import("@anywidget/types").Render;
+	default?: AnyWidget | (() => AnyWidget | Promise<AnyWidget>);
+};
diff --git a/packages/anywidget/src/util.js b/packages/anywidget/src/util.js
new file mode 100644
index 00000000..338e3dc4
--- /dev/null
+++ b/packages/anywidget/src/util.js
@@ -0,0 +1,354 @@
+/**
+ * @param {unknown} obj
+ * @returns {boolean}
+ */
+export function is_object(obj) {
+	return typeof obj === "object" && obj !== null;
+}
+
+/**
+ * @param {unknown} condition
+ * @param {string} msg
+ * @returns {asserts condition}
+ */
+export function assert(condition, msg) {
+	if (!condition) {
+		throw new Error(msg);
+	}
+}
+
+/**
+ * Takes an object 'state' and fills in buffer[i] at 'path' buffer_paths[i]
+ * where buffer_paths[i] is a list indicating where in the object buffer[i] should
+ * be placed
+ * Example: state = {a: 1, b: {}, c: [0, null]}
+ * buffers = [array1, array2]
+ * buffer_paths = [['b', 'data'], ['c', 1]]
+ * Will lead to {a: 1, b: {data: array1}, c: [0, array2]}
+ *
+ * @param {Record<string, unknown>} state
+ * @param {ReadonlyArray<ReadonlyArray<string | number>>} buffer_paths
+ * @param {ReadonlyArray<ArrayBufferLike | DataView>} buffers
+ */
+export function put_buffers(state, buffer_paths = [], buffers = []) {
+	let data_views = buffers.map((b) => {
+		if (b instanceof DataView) return b;
+		if (b instanceof ArrayBuffer) return new DataView(b);
+		throw new Error("Unknown buffer type: " + b);
+	});
+	assert(
+		buffer_paths.length === data_views.length,
+		"Not the same number of buffer_paths and buffers",
+	);
+	for (let i = 0; i < buffer_paths.length; i++) {
+		let buffer = buffers[i];
+		let buffer_path = buffer_paths[i];
+
+		// say we want to set state[x][y][z] = buffer
+		/** @type {any} */
+		let node = state;
+		// we first get obj = state[x][y]
+		for (let path of buffer_path.slice(0, -1)) {
+			node = node[path];
+		}
+		// and then set: obj[z] = buffer
+		node[buffer_path[buffer_path.length - 1]] = buffer;
+	}
+}
+
+/**
+ * @param {Record<string, unknown>} state
+ * @returns {{
+ *   state: import("./types.js").JSONValue,
+ *   buffer_paths: Array<Array<string | number>>,
+ *   buffers: Array<ArrayBuffer>
+ * }}
+ */
+export function extract_buffers(state) {
+	/** @type {Array<Array<string | number>>} */
+	let buffer_paths = [];
+	/** @type {Array<ArrayBuffer>} */
+	let buffers = [];
+	/**
+	 * @param {any} obj
+	 * @param {any} parent
+	 * @param {string | number | null} key_in_parent
+	 * @param {Array<string | number>} path
+	 */
+	function extract_buffers_and_paths(
+		obj,
+		parent = null,
+		key_in_parent = null,
+		path = [],
+	) {
+		if (obj instanceof ArrayBuffer || obj instanceof DataView) {
+			buffer_paths.push([...path]);
+			buffers.push("buffer" in obj ? obj.buffer : obj);
+			if (parent !== null && key_in_parent !== null) {
+				// mutate the parent to remove the buffer
+				parent[key_in_parent] = null;
+			}
+			return;
+		}
+		if (is_object(obj)) {
+			for (let [key, value] of Object.entries(obj)) {
+				extract_buffers_and_paths(value, obj, key, path.concat(key));
+			}
+		}
+		if (Array.isArray(obj)) {
+			for (let i = 0; i < obj.length; i++) {
+				extract_buffers_and_paths(obj[i], obj, i, path.concat(i));
+			}
+		}
+	}
+	extract_buffers_and_paths(state);
+	/** @type {import("./types.js").JSONValue} */
+	// @ts-expect-error - TODO: fix type
+	let json_state = state;
+	return { state: json_state, buffer_paths, buffers };
+}
+
+/**
+ * @param {import("./types.js").CommMessage} msg
+ * @returns {msg is import("./types.js").CustomMessage}
+ */
+export function is_custom_msg(msg) {
+	return msg.content.data.method === "custom";
+}
+
+/**
+ * @param {import("./types.js").CommMessage} msg
+ * @returns {msg is import("./types.js").UpdateMessage | import("./types.js").EchoUpdateMessage}
+ */
+export function is_update_msg(msg) {
+	return msg.content.data.method === "update" ||
+		msg.content.data.method === "echo_update";
+}
+
+/**
+ * @param {import("./types.js").UpdateMessage | import("./types.js").EchoUpdateMessage} msg
+ * @return {msg is import("./types.js").EchoUpdateMessage}
+ */
+export function is_echo_update_msg(msg) {
+	return msg.content.data.method === "echo_update" &&
+		!!msg.parent_header?.msg_id;
+}
+
+/**
+ * @param {string} str
+ * @returns {str is "https://${string}" | "http://${string}"}
+ */
+export function is_href(str) {
+	return str.startsWith("http://") || str.startsWith("https://");
+}
+
+/**
+ * @param {string} href
+ * @param {string} anywidget_id
+ * @returns {Promise<void>}
+ */
+async function load_css_href(href, anywidget_id) {
+	/** @type {HTMLLinkElement | null} */
+	let prev = document.querySelector(`link[id='${anywidget_id}']`);
+
+	// Adapted from https://github.com/vitejs/vite/blob/d59e1acc2efc0307488364e9f2fad528ec57f204/packages/vite/src/client/client.ts#L185-L201
+	// Swaps out old styles with new, but avoids flash of unstyled content.
+	// No need to await the load since we already have styles applied.
+	if (prev) {
+		let newLink = /** @type {HTMLLinkElement} */ (prev.cloneNode());
+		newLink.href = href;
+		newLink.addEventListener("load", () => prev?.remove());
+		newLink.addEventListener("error", () => prev?.remove());
+		prev.after(newLink);
+		return;
+	}
+
+	return new Promise((resolve) => {
+		let link = Object.assign(document.createElement("link"), {
+			rel: "stylesheet",
+			href,
+			onload: resolve,
+		});
+		document.head.appendChild(link);
+	});
+}
+
+/**
+ * @param {string} css_text
+ * @param {string} anywidget_id
+ * @returns {void}
+ */
+function load_css_text(css_text, anywidget_id) {
+	/** @type {HTMLStyleElement | null} */
+	let prev = document.querySelector(`style[id='${anywidget_id}']`);
+	if (prev) {
+		// replace instead of creating a new DOM node
+		prev.textContent = css_text;
+		return;
+	}
+	let style = Object.assign(document.createElement("style"), {
+		id: anywidget_id,
+		type: "text/css",
+	});
+	style.appendChild(document.createTextNode(css_text));
+	document.head.appendChild(style);
+}
+
+/**
+ * @param {string | undefined} css
+ * @param {string} anywidget_id
+ * @returns {Promise<void>}
+ */
+export async function load_css(css, anywidget_id) {
+	if (!css || !anywidget_id) return;
+	if (is_href(css)) return load_css_href(css, anywidget_id);
+	return load_css_text(css, anywidget_id);
+}
+
+/**
+ * @param {string} esm
+ * @returns {Promise<{ mod: import("./types.js").AnyWidgetModule, url: string }>}
+ */
+export async function load_esm(esm) {
+	if (is_href(esm)) {
+		return {
+			mod: await import(/* webpackIgnore: true */ esm),
+			url: esm,
+		};
+	}
+	let url = URL.createObjectURL(new Blob([esm], { type: "text/javascript" }));
+	let mod = await import(/* webpackIgnore: true */ url);
+	URL.revokeObjectURL(url);
+	return { mod, url };
+}
+
+function warn_render_deprecation() {
+	console.warn(`\
+[anywidget] Deprecation Warning. Direct export of a 'render' will likely be deprecated in the future. To migrate ...
+
+Remove the 'export' keyword from 'render'
+-----------------------------------------
+
+export function render({ model, el }) { ... }
+^^^^^^
+
+Create a default export that returns an object with 'render'
+------------------------------------------------------------
+
+function render({ model, el }) { ... }
+         ^^^^^^
+export default { render }
+                 ^^^^^^
+
+To learn more, please see: https://github.com/manzt/anywidget/pull/395
+`);
+}
+
+/**
+ * @param {string} esm
+ * @returns {Promise<import("./types.js").AnyWidget & { url: string }>}
+ */
+export async function load_widget(esm) {
+	let { mod, url } = await load_esm(esm);
+	if (mod.render) {
+		warn_render_deprecation();
+		return {
+			url,
+			async initialize() {},
+			render: mod.render,
+		};
+	}
+	assert(
+		mod.default,
+		`[anywidget] module must export a default function or object.`,
+	);
+	let widget = typeof mod.default === "function"
+		? await mod.default()
+		: mod.default;
+	return { url, ...widget };
+}
+
+/**
+ * @template {Record<string, any>} T
+ *
+ * @param {import('./model.js').Model<T>} model
+ * @param {unknown} context
+ * @return {import("@anywidget/types").AnyModel}
+ *
+ * Prunes the view down to the minimum context necessary.
+ *
+ * Calls to `model.get` and `model.set` automatically add the
+ * `context`, so we can gracefully unsubscribe from events
+ * added by user-defined hooks.
+ */
+export function model_proxy(model, context) {
+	return {
+		get: model.get.bind(model),
+		set: model.set.bind(model),
+		save_changes: model.save_changes.bind(model),
+		send: model.send.bind(model),
+		// @ts-expect-error
+		on(name, callback) {
+			model.on(name, callback, context);
+		},
+		off(name, callback) {
+			model.off(name, callback, context);
+		},
+		widget_manager: model.widget_manager,
+	};
+}
+
+/**
+ * @param {void | (() => import('vitest').Awaitable<void>)} fn
+ * @param {string} kind
+ */
+export async function safe_cleanup(fn, kind) {
+	return Promise.resolve()
+		.then(() => fn?.())
+		.catch((e) => console.warn(`[anywidget] error cleaning up ${kind}.`, e));
+}
+
+/**
+ * @template T
+ * @typedef {{ data: T, state: "ok" } | { error: any, state: "error" }} Result
+ */
+
+/** @type {<T>(data: T) => Result<T>} */
+export function ok(data) {
+	return { data, state: "ok" };
+}
+
+/** @type {(e: any) => Result<any>} */
+export function error(e) {
+	return { error: e, state: "error" };
+}
+
+/**
+ * Cleans up the stack trace at anywidget boundary.
+ * You can fully inspect the entire stack trace in the console interactively,
+ * but the initial error message is cleaned up to be more user-friendly.
+ *
+ * @param {unknown} source
+ * @returns {never}
+ */
+export function throw_anywidget_error(source) {
+	if (!(source instanceof Error)) {
+		// Don't know what to do with this.
+		throw source;
+	}
+	let lines = source.stack?.split("\n") ?? [];
+	let anywidget_index = lines.findIndex((line) => line.includes("anywidget"));
+	let clean_stack = anywidget_index === -1
+		? lines
+		: lines.slice(0, anywidget_index + 1);
+	source.stack = clean_stack.join("\n");
+	console.error(source);
+	throw source;
+}
+
+/** @param {HTMLElement} el */
+export function empty(el) {
+	while (el.firstChild) {
+		el.removeChild(el.firstChild);
+	}
+}
diff --git a/packages/anywidget/src/view.js b/packages/anywidget/src/view.js
new file mode 100644
index 00000000..60c7bf05
--- /dev/null
+++ b/packages/anywidget/src/view.js
@@ -0,0 +1,85 @@
+import * as util from "./util.js";
+import { Widget } from "@lumino/widgets";
+
+/**
+ * @typedef LuminoMessage
+ * @property {string} type
+ * @property {boolean} isConflatable
+ * @property {(msg: LuminoMessage) => boolean} conflate
+ */
+
+/**
+ * @template {Record<string, unknown>} T
+ * @typedef {import("./model.js").Model<T>} Model
+ */
+
+/**
+ * @template {Record<string, unknown>} T
+ * @typedef ViewOptions
+ * @prop {Model<T>} model
+ * @prop {HTMLElement} [el]
+ * @prop {string} [id]
+ */
+
+/**
+ * @template {Record<string, unknown>} T
+ */
+export class View {
+	/** @type {HTMLElement} */
+	el;
+	/** @type {Model<T>} */
+	model;
+	/** @type {Record<string, unknown>} */
+	options;
+	#remove_callback = () => {};
+
+	/** @param {ViewOptions<T>} options */
+	constructor({ el, model, ...options }) {
+		this.el = el ?? document.createElement("div");
+		this.model = model;
+		this.options = options;
+		// TODO: We should try to drop the Lumino dependency. However, this seems required for all widgets.
+		this.luminoWidget = new Widget({ node: this.el });
+	}
+
+	/**
+	 * @param {Model<T>} model
+	 * @param {"destroy"} name
+	 * @param {() => void} callback
+	 */
+	listenTo(model, name, callback) {
+		util.assert(
+			name === "destroy",
+			"[anywidget] Only 'destroy' event is supported in `View.listenTo`",
+		);
+	}
+
+	/**
+	 * @param {"remove"} name
+	 * @param {() => void} callback
+	 */
+	once(name, callback) {
+		util.assert(
+			name === "remove",
+			"[anywidget] Only 'remove' event is supported in `View.once`",
+		);
+		this.#remove_callback = callback;
+	}
+
+	remove() {
+		this.luminoWidget?.dispose();
+		this.#remove_callback();
+		util.empty(this.el);
+		this.el.remove();
+		this.model.off(null, null, this);
+	}
+
+	/**
+	 * Render the view.
+	 *
+	 * Should be overridden by subclasses.
+	 *
+	 * @returns {Promise<void>}
+	 */
+	async render() {}
+}
diff --git a/packages/anywidget/src/widget.js b/packages/anywidget/src/widget.js
index 8e1167dc..c8eac385 100644
--- a/packages/anywidget/src/widget.js
+++ b/packages/anywidget/src/widget.js
@@ -1,375 +1,22 @@
-import {
-	createEffect,
-	createResource,
-	createRoot,
-	createSignal,
-} from "solid-js";
+import * as util from "./util.js";
+import { Runtime } from "./runtime.js";
+import { Model } from "./model.js";
+import { View } from "./view.js";
 
-/**
- * @typedef AnyWidget
- * @prop initialize {import("@anywidget/types").Initialize}
- * @prop render {import("@anywidget/types").Render}
- */
-
-/**
- *  @typedef AnyWidgetModule
- *  @prop render {import("@anywidget/types").Render=}
- *  @prop default {AnyWidget | (() => AnyWidget | Promise<AnyWidget>)=}
- */
-
-/**
- * @param {any} condition
- * @param {string} message
- * @returns {asserts condition}
- */
-function assert(condition, message) {
-	if (!condition) throw new Error(message);
-}
-
-/**
- * @param {string} str
- * @returns {str is "https://${string}" | "http://${string}"}
- */
-function is_href(str) {
-	return str.startsWith("http://") || str.startsWith("https://");
-}
-
-/**
- * @param {string} href
- * @param {string} anywidget_id
- * @returns {Promise<void>}
- */
-async function load_css_href(href, anywidget_id) {
-	/** @type {HTMLLinkElement | null} */
-	let prev = document.querySelector(`link[id='${anywidget_id}']`);
-
-	// Adapted from https://github.com/vitejs/vite/blob/d59e1acc2efc0307488364e9f2fad528ec57f204/packages/vite/src/client/client.ts#L185-L201
-	// Swaps out old styles with new, but avoids flash of unstyled content.
-	// No need to await the load since we already have styles applied.
-	if (prev) {
-		let newLink = /** @type {HTMLLinkElement} */ (prev.cloneNode());
-		newLink.href = href;
-		newLink.addEventListener("load", () => prev?.remove());
-		newLink.addEventListener("error", () => prev?.remove());
-		prev.after(newLink);
-		return;
-	}
-
-	return new Promise((resolve) => {
-		let link = Object.assign(document.createElement("link"), {
-			rel: "stylesheet",
-			href,
-			onload: resolve,
-		});
-		document.head.appendChild(link);
-	});
-}
-
-/**
- * @param {string} css_text
- * @param {string} anywidget_id
- * @returns {void}
- */
-function load_css_text(css_text, anywidget_id) {
-	/** @type {HTMLStyleElement | null} */
-	let prev = document.querySelector(`style[id='${anywidget_id}']`);
-	if (prev) {
-		// replace instead of creating a new DOM node
-		prev.textContent = css_text;
-		return;
-	}
-	let style = Object.assign(document.createElement("style"), {
-		id: anywidget_id,
-		type: "text/css",
-	});
-	style.appendChild(document.createTextNode(css_text));
-	document.head.appendChild(style);
-}
-
-/**
- * @param {string | undefined} css
- * @param {string} anywidget_id
- * @returns {Promise<void>}
- */
-async function load_css(css, anywidget_id) {
-	if (!css || !anywidget_id) return;
-	if (is_href(css)) return load_css_href(css, anywidget_id);
-	return load_css_text(css, anywidget_id);
-}
-
-/**
- * @param {string} esm
- * @returns {Promise<{ mod: AnyWidgetModule, url: string }>}
- */
-async function load_esm(esm) {
-	if (is_href(esm)) {
-		return {
-			mod: await import(/* webpackIgnore: true */ esm),
-			url: esm,
-		};
-	}
-	let url = URL.createObjectURL(new Blob([esm], { type: "text/javascript" }));
-	let mod = await import(/* webpackIgnore: true */ url);
-	URL.revokeObjectURL(url);
-	return { mod, url };
-}
-
-function warn_render_deprecation() {
-	console.warn(`\
-[anywidget] Deprecation Warning. Direct export of a 'render' will likely be deprecated in the future. To migrate ...
-
-Remove the 'export' keyword from 'render'
------------------------------------------
-
-export function render({ model, el }) { ... }
-^^^^^^
-
-Create a default export that returns an object with 'render'
-------------------------------------------------------------
-
-function render({ model, el }) { ... }
-         ^^^^^^
-export default { render }
-                 ^^^^^^
-
-To learn more, please see: https://github.com/manzt/anywidget/pull/395
-`);
-}
-
-/**
- * @param {string} esm
- * @returns {Promise<AnyWidget & { url: string }>}
- */
-async function load_widget(esm) {
-	let { mod, url } = await load_esm(esm);
-	if (mod.render) {
-		warn_render_deprecation();
-		return {
-			url,
-			async initialize() {},
-			render: mod.render,
-		};
-	}
-	assert(
-		mod.default,
-		`[anywidget] module must export a default function or object.`,
-	);
-	let widget = typeof mod.default === "function"
-		? await mod.default()
-		: mod.default;
-	return { url, ...widget };
-}
-
-/**
- * This is a trick so that we can cleanup event listeners added
- * by the user-defined function.
- */
-let INITIALIZE_MARKER = Symbol("anywidget.initialize");
-
-/**
- * @param {import("@jupyter-widgets/base").DOMWidgetModel} model
- * @param {unknown} context
- * @return {import("@anywidget/types").AnyModel}
- *
- * Prunes the view down to the minimum context necessary.
- *
- * Calls to `model.get` and `model.set` automatically add the
- * `context`, so we can gracefully unsubscribe from events
- * added by user-defined hooks.
- */
-function model_proxy(model, context) {
-	return {
-		get: model.get.bind(model),
-		set: model.set.bind(model),
-		save_changes: model.save_changes.bind(model),
-		send: model.send.bind(model),
-		// @ts-expect-error
-		on(name, callback) {
-			model.on(name, callback, context);
-		},
-		off(name, callback) {
-			model.off(name, callback, context);
-		},
-		widget_manager: model.widget_manager,
-	};
-}
-
-/**
- * @param {void | (() => import('vitest').Awaitable<void>)} fn
- * @param {string} kind
- */
-async function safe_cleanup(fn, kind) {
-	return Promise.resolve()
-		.then(() => fn?.())
-		.catch((e) => console.warn(`[anywidget] error cleaning up ${kind}.`, e));
-}
-
-/**
- * @template T
- * @typedef {{ data: T, state: "ok" } | { error: any, state: "error" }} Result
- */
-
-/** @type {<T>(data: T) => Result<T>} */
-function ok(data) {
-	return { data, state: "ok" };
-}
-
-/** @type {(e: any) => Result<any>} */
-function error(e) {
-	return { error: e, state: "error" };
-}
-
-/**
- * Cleans up the stack trace at anywidget boundary.
- * You can fully inspect the entire stack trace in the console interactively,
- * but the initial error message is cleaned up to be more user-friendly.
- *
- * @param {unknown} source
- * @returns {never}
- */
-function throw_anywidget_error(source) {
-	if (!(source instanceof Error)) {
-		// Don't know what to do with this.
-		throw source;
-	}
-	let lines = source.stack?.split("\n") ?? [];
-	let anywidget_index = lines.findIndex((line) => line.includes("anywidget"));
-	let clean_stack = anywidget_index === -1
-		? lines
-		: lines.slice(0, anywidget_index + 1);
-	source.stack = clean_stack.join("\n");
-	console.error(source);
-	throw source;
-}
-
-class Runtime {
-	/** @type {() => void} */
-	#disposer = () => {};
-	/** @type {Set<() => void>} */
-	#view_disposers = new Set();
-	/** @type {import('solid-js').Resource<Result<AnyWidget & { url: string }>>} */
-	// @ts-expect-error - Set synchronously in constructor.
-	#widget_result;
-
-	/** @param {import("@jupyter-widgets/base").DOMWidgetModel} model */
-	constructor(model) {
-		this.#disposer = createRoot((dispose) => {
-			let [css, set_css] = createSignal(model.get("_css"));
-			model.on("change:_css", () => {
-				let id = model.get("_anywidget_id");
-				console.debug(`[anywidget] css hot updated: ${id}`);
-				set_css(model.get("_css"));
-			});
-			createEffect(() => {
-				let id = model.get("_anywidget_id");
-				load_css(css(), id);
-			});
-
-			/** @type {import("solid-js").Signal<string>} */
-			let [esm, setEsm] = createSignal(model.get("_esm"));
-			model.on("change:_esm", async () => {
-				let id = model.get("_anywidget_id");
-				console.debug(`[anywidget] esm hot updated: ${id}`);
-				setEsm(model.get("_esm"));
-			});
-			/** @type {void | (() => import("vitest").Awaitable<void>)} */
-			let cleanup;
-			this.#widget_result = createResource(esm, async (update) => {
-				await safe_cleanup(cleanup, "initialize");
-				try {
-					model.off(null, null, INITIALIZE_MARKER);
-					let widget = await load_widget(update);
-					cleanup = await widget.initialize?.({
-						model: model_proxy(model, INITIALIZE_MARKER),
-					});
-					return ok(widget);
-				} catch (e) {
-					return error(e);
-				}
-			})[0];
-			return () => {
-				cleanup?.();
-				model.off("change:_css");
-				model.off("change:_esm");
-				dispose();
-			};
-		});
-	}
+export default function () {
+	/** @type {WeakMap<AnyModel<any>, Runtime>} */
+	let RUNTIMES = new WeakMap();
 
 	/**
-	 * @param {import("@jupyter-widgets/base").DOMWidgetView} view
-	 * @returns {Promise<() => void>}
+	 * @template {{ _esm: string, _css?: string, _anywidget_id: string }} T
+	 * @extends {Model<T>}
 	 */
-	async create_view(view) {
-		let model = view.model;
-		let disposer = createRoot((dispose) => {
-			/** @type {void | (() => import("vitest").Awaitable<void>)} */
-			let cleanup;
-			let resource =
-				createResource(this.#widget_result, async (widget_result) => {
-					cleanup?.();
-					// Clear all previous event listeners from this hook.
-					model.off(null, null, view);
-					view.$el.empty();
-					if (widget_result.state === "error") {
-						throw_anywidget_error(widget_result.error);
-					}
-					let widget = widget_result.data;
-					try {
-						cleanup = await widget.render?.({
-							model: model_proxy(model, view),
-							el: view.el,
-						});
-					} catch (e) {
-						throw_anywidget_error(e);
-					}
-				})[0];
-			createEffect(() => {
-				if (resource.error) {
-					// TODO: Show error in the view?
-				}
-			});
-			return () => {
-				dispose();
-				cleanup?.();
-			};
-		});
-		// Have the runtime keep track but allow the view to dispose itself.
-		this.#view_disposers.add(disposer);
-		return () => {
-			let deleted = this.#view_disposers.delete(disposer);
-			if (deleted) disposer();
-		};
-	}
-
-	dispose() {
-		this.#view_disposers.forEach((dispose) => dispose());
-		this.#view_disposers.clear();
-		this.#disposer();
-	}
-}
-
-// @ts-expect-error - injected by bundler
-let version = globalThis.VERSION;
-
-/** @param {typeof import("@jupyter-widgets/base")} base */
-export default function ({ DOMWidgetModel, DOMWidgetView }) {
-	/** @type {WeakMap<AnyModel, Runtime>} */
-	let RUNTIMES = new WeakMap();
-
-	class AnyModel extends DOMWidgetModel {
-		static model_name = "AnyModel";
-		static model_module = "anywidget";
-		static model_module_version = version;
-
-		static view_name = "AnyView";
-		static view_module = "anywidget";
-		static view_module_version = version;
-
-		/** @param {Parameters<InstanceType<DOMWidgetModel>["initialize"]>} args */
-		initialize(...args) {
-			super.initialize(...args);
+	class AnyModel extends Model {
+		/** @param {ConstructorParameters<typeof Model<T>>} args */
+		constructor(...args) {
+			super(...args);
 			let runtime = new Runtime(this);
+			window.model = this;
 			this.once("destroy", () => {
 				try {
 					runtime.dispose();
@@ -379,52 +26,19 @@ export default function ({ DOMWidgetModel, DOMWidgetView }) {
 			});
 			RUNTIMES.set(this, runtime);
 		}
-
-		/**
-		 * @param {Record<string, any>} state
-		 *
-		 * We override to support binary trailets because JSON.parse(JSON.stringify())
-		 * does not properly clone binary data (it just returns an empty object).
-		 *
-		 * https://github.com/jupyter-widgets/ipywidgets/blob/47058a373d2c2b3acf101677b2745e14b76dd74b/packages/base/src/widget.ts#L562-L583
-		 */
-		serialize(state) {
-			let serializers =
-				/** @type {DOMWidgetModel} */ (this.constructor).serializers || {};
-			for (let k of Object.keys(state)) {
-				try {
-					let serialize = serializers[k]?.serialize;
-					if (serialize) {
-						state[k] = serialize(state[k], this);
-					} else if (k === "layout" || k === "style") {
-						// These keys come from ipywidgets, rely on JSON.stringify trick.
-						state[k] = JSON.parse(JSON.stringify(state[k]));
-					} else {
-						state[k] = structuredClone(state[k]);
-					}
-					if (typeof state[k]?.toJSON === "function") {
-						state[k] = state[k].toJSON();
-					}
-				} catch (e) {
-					console.error("Error serializing widget state attribute: ", k);
-					throw e;
-				}
-			}
-			return state;
-		}
 	}
 
-	class AnyView extends DOMWidgetView {
-		/** @type {undefined | (() => void)} */
-		#dispose = undefined;
+	/** @extends {View<any>} */
+	class AnyView extends View {
+		/** @type {() => void} */
+		#dispose = () => {};
 		async render() {
-			let runtime = RUNTIMES.get(this.model);
-			assert(runtime, "[anywidget] runtime not found.");
-			assert(!this.#dispose, "[anywidget] dispose already set.");
+			let runtime = RUNTIMES.get(/** @type {any} */ (this.model));
+			util.assert(runtime, "[anywidget] runtime not found.");
 			this.#dispose = await runtime.create_view(this);
 		}
 		remove() {
-			this.#dispose?.();
+			this.#dispose();
 			super.remove();
 		}
 	}
diff --git a/packages/types/index.ts b/packages/types/index.ts
index eb874c6d..b3b34cd7 100644
--- a/packages/types/index.ts
+++ b/packages/types/index.ts
@@ -40,7 +40,7 @@ export interface AnyModel<T extends ObjectHash = ObjectHash> {
 		callbacks?: any,
 		buffers?: ArrayBuffer[] | ArrayBufferView[],
 	): void;
-	widget_manager: IWidgetManager;
+	widget_manager: Pick<IWidgetManager, "get_model">;
 }
 
 export interface RenderProps<T extends ObjectHash = ObjectHash> {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a04dd068..eede6533 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1,4 +1,4 @@
-lockfileVersion: '6.1'
+lockfileVersion: '6.0'
 
 settings:
   autoInstallPeers: true
@@ -114,6 +114,9 @@ importers:
       '@jupyter-widgets/base':
         specifier: ^2 || ^3 || ^4 || ^5 || ^6
         version: 6.0.7(react@18.2.0)
+      '@lumino/widgets':
+        specifier: ^2.3.1
+        version: 2.3.1
       solid-js:
         specifier: ^1.8.14
         version: 1.8.14