diff --git a/.chronus/changes/witemple-msft-custom-element-2025-6-22-19-3-51.md b/.chronus/changes/witemple-msft-custom-element-2025-6-22-19-3-51.md
new file mode 100644
index 000000000..81f0a6dba
--- /dev/null
+++ b/.chronus/changes/witemple-msft-custom-element-2025-6-22-19-3-51.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+ - "@alloy-js/core"
+---
+
+Adds a new type of Alloy child, CustomChildElement, that is based on the presence of a symbol property.
\ No newline at end of file
diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts
index 1d3d99b6d..c63a66a80 100644
--- a/packages/core/src/render.ts
+++ b/packages/core/src/render.ts
@@ -20,7 +20,9 @@ import {
Children,
Component,
isComponentCreator,
+ isRenderableObject,
Props,
+ RENDERABLE,
} from "./runtime/component.js";
import { IntrinsicElement, isIntrinsicElement } from "./runtime/intrinsic.js";
import { flushJobs, flushJobsAsync } from "./scheduler.js";
@@ -548,6 +550,9 @@ function normalizeChild(child: Child): NormalizedChildren {
return sfContext.reference({ refkey: child });
};
+ } else if (isRenderableObject(child)) {
+ // For custom renderable objects, we will just normalize them to a bound function.
+ return child[RENDERABLE].bind(child);
} else if (isCustomContext(child)) {
return child;
} else if (isIntrinsicElement(child)) {
@@ -573,6 +578,8 @@ function debugPrintChild(child: Children): string {
return "$ref";
} else if (isIntrinsicElement(child)) {
return `<${child.name}>`;
+ } else if (isRenderableObject(child)) {
+ return `CustomChildElement(${JSON.stringify(child)})`;
} else {
return JSON.stringify(child);
}
diff --git a/packages/core/src/runtime/component.ts b/packages/core/src/runtime/component.ts
index c4727d7a2..53c13321f 100644
--- a/packages/core/src/runtime/component.ts
+++ b/packages/core/src/runtime/component.ts
@@ -3,7 +3,38 @@ import { CustomContext } from "../reactivity.js";
import { Refkey } from "../refkey.js";
import { IntrinsicElement } from "./intrinsic.js";
+export const RENDERABLE = Symbol.for("Alloy.CustomElement");
+
+/**
+ * A renderable object is any object that has an `[ay.RENDERABLE]` method that
+ * returns children. This is used to allow custom object types to be used as
+ * children in Alloy components.
+ */
+export interface RenderableObject {
+ /**
+ * Renders this object to children.
+ */
+ [RENDERABLE](): Children;
+}
+
+/**
+ * Returns true if the item is a renderable object, meaning it has an `[ay.RENDERABLE]`
+ * method.
+ *
+ * @param item - The item to check.
+ * @returns True if the item is a renderable object.
+ */
+export function isRenderableObject(item: unknown): item is RenderableObject {
+ return (
+ typeof item === "object" &&
+ item !== null &&
+ RENDERABLE in item &&
+ typeof (item as any)[RENDERABLE] === "function"
+ );
+}
+
export type Child =
+ | RenderableObject
| string
| boolean
| number
diff --git a/packages/core/test/rendering/basic.test.tsx b/packages/core/test/rendering/basic.test.tsx
index 6e0ab6262..10e115d69 100644
--- a/packages/core/test/rendering/basic.test.tsx
+++ b/packages/core/test/rendering/basic.test.tsx
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { Children } from "../../src/runtime/component.js";
+import { Children, RENDERABLE } from "../../src/runtime/component.js";
import "../../testing/extend-expect.js";
describe("string nodes", () => {
it("renders string nodes with substitutions", () => {
@@ -49,6 +49,40 @@ describe("component nodes", () => {
>,
).toRenderTo("Str Str");
});
+
+ it("renders custom elements", () => {
+ const customElement = {
+ [RENDERABLE]() {
+ return (
+ <>
+
+ >
+ );
+ },
+ };
+
+ expect(customElement).toRenderTo("Str Str");
+ });
+
+ it("renders nested custom elements", () => {
+ const e1 = {
+ [RENDERABLE]() {
+ return ;
+ },
+ };
+
+ const e2 = {
+ [RENDERABLE]() {
+ return (
+ <>
+ {e1} {e1}
+ >
+ );
+ },
+ };
+
+ expect(e2).toRenderTo("Str Str");
+ });
});
describe("memo nodes", () => {