diff --git a/demo/projector.html b/demo/projector.html
new file mode 100644
index 0000000..aab37fd
--- /dev/null
+++ b/demo/projector.html
@@ -0,0 +1,92 @@
+
+
+
+
+
+ Projector
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/folk-projector.ts b/src/folk-projector.ts
new file mode 100644
index 0000000..495d293
--- /dev/null
+++ b/src/folk-projector.ts
@@ -0,0 +1,151 @@
+import { DOMRectTransform } from './common/DOMRectTransform';
+import { FolkShape } from './folk-shape';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'folk-projector': FolkProjector;
+ }
+}
+
+export class FolkProjector extends HTMLElement {
+ static tagName = 'folk-projector';
+
+ static define() {
+ if (customElements.get(this.tagName)) return;
+ customElements.define(this.tagName, this);
+ }
+
+ #isProjecting = false;
+ #mappedElements = new Map();
+ #isTransitioning = false;
+
+ constructor() {
+ super();
+ const style = document.createElement('style');
+ style.textContent = `
+ :host {
+ display: block;
+ position: relative;
+ }
+ /* Add styles for mapped elements and their children */
+ div {
+ opacity: 1;
+ box-sizing: border-box;
+ display: flex;
+ flex-wrap: wrap;
+ position: absolute;
+ }
+ div input {
+ width: 50%;
+ min-width: 0;
+ font-size: 12px;
+ box-sizing: border-box;
+ }
+ `;
+ this.appendChild(style);
+ }
+
+ mapping(shape: FolkShape, mappingFn: (element: FolkShape) => HTMLElement) {
+ const mappedEl = mappingFn(shape);
+ const rect = shape.getTransformDOMRect();
+
+ mappedEl.style.position = 'absolute';
+ mappedEl.style.width = rect.width + 'px';
+ mappedEl.style.height = rect.height + 'px';
+ mappedEl.style.transform = shape.style.transform;
+ mappedEl.style.transformOrigin = '0 0';
+ mappedEl.style.opacity = '0';
+ mappedEl.style.pointerEvents = 'all';
+
+ this.appendChild(mappedEl);
+ this.#mappedElements.set(shape, mappedEl);
+ }
+
+ async project(spacing = 20) {
+ if (this.#isTransitioning) return;
+ this.#isTransitioning = true;
+
+ const shapes = Array.from(this.children).filter((el): el is FolkShape => el instanceof FolkShape);
+
+ const mappedElements = shapes
+ .map((shape) => this.#mappedElements.get(shape))
+ .filter((el): el is HTMLElement => el !== null);
+
+ // Ensure elements are painted before transition
+ await new Promise(requestAnimationFrame);
+
+ const CELL_WIDTH = 100;
+ const CELL_HEIGHT = 50;
+ const X_OFFSET = 20;
+
+ let yOffset = 0;
+
+ const positions = shapes.map((shape) => {
+ if (this.#isProjecting) {
+ return shape.getTransformDOMRect();
+ } else {
+ const newRect = new DOMRectTransform({
+ x: X_OFFSET,
+ y: yOffset,
+ width: CELL_WIDTH,
+ height: CELL_HEIGHT,
+ rotation: 0,
+ });
+
+ yOffset += CELL_HEIGHT + spacing;
+ return newRect;
+ }
+ });
+
+ if (!document.startViewTransition) {
+ shapes.forEach((shape, i) => {
+ const newTransform = positions[i].toCssString();
+ const newRect = positions[i];
+ shape.style.transform = newTransform;
+ shape.style.width = `${newRect.width}px`;
+ shape.style.height = `${newRect.height}px`;
+ const mappedEl = mappedElements[i];
+ if (mappedEl) {
+ mappedEl.style.transform = newTransform;
+ mappedEl.style.width = `${newRect.width}px`;
+ mappedEl.style.height = `${newRect.height}px`;
+ mappedEl.style.opacity = this.#isProjecting ? '1' : '0';
+ }
+ });
+ } else {
+ shapes.forEach((shape, i) => {
+ shape.style.viewTransitionName = `shape-${i}`;
+ mappedElements[i].style.viewTransitionName = `mapped-${i}`;
+ });
+
+ await new Promise(requestAnimationFrame);
+
+ const transition = document.startViewTransition(() => {
+ shapes.forEach((shape, i) => {
+ const newTransform = positions[i].toCssString();
+ const newRect = positions[i];
+ shape.style.transform = newTransform;
+ shape.style.width = `${newRect.width}px`;
+ shape.style.height = `${newRect.height}px`;
+ const mappedEl = mappedElements[i];
+ if (mappedEl) {
+ mappedEl.style.transform = newTransform;
+ mappedEl.style.width = `${newRect.width}px`;
+ mappedEl.style.height = `${newRect.height}px`;
+ mappedEl.style.opacity = this.#isProjecting ? '1' : '0';
+ }
+ });
+ });
+
+ transition.finished.finally(() => {
+ this.#isTransitioning = false;
+ shapes.forEach((shape, i) => {
+ shape.style.viewTransitionName = '';
+ mappedElements[i].style.viewTransitionName = '';
+ });
+ });
+ }
+
+ this.#isProjecting = !this.#isProjecting;
+ }
+}
diff --git a/src/standalone/folk-projector.ts b/src/standalone/folk-projector.ts
new file mode 100644
index 0000000..e15f3e9
--- /dev/null
+++ b/src/standalone/folk-projector.ts
@@ -0,0 +1,5 @@
+import { FolkProjector } from '../folk-projector';
+
+FolkProjector.define();
+
+export { FolkProjector };