diff --git a/frontend/packages/ui/AGENTS.md b/frontend/packages/ui/AGENTS.md
index 599b2be8e1..963fb90ca1 100644
--- a/frontend/packages/ui/AGENTS.md
+++ b/frontend/packages/ui/AGENTS.md
@@ -63,6 +63,7 @@ Components are organised to mirror the CLJS source tree
| `ds/foundations/assets/icon.cljs` | `src/lib/foundations/assets/Icon.tsx` |
| `ds/foundations/assets/raw_svg.cljs` | `src/lib/foundations/assets/RawSvg.tsx` |
| `ds/product/cta.cljs` | `src/lib/product/Cta.tsx` |
+| `ds/product/loader.cljs` | `src/lib/product/Loader.tsx` |
| `ds/buttons/button.cljs` | `src/lib/buttons/Button.tsx` |
| `ds/buttons/icon_button.cljs` | `src/lib/buttons/IconButton.tsx` |
| `ds/utilities/swatch.cljs` | `src/lib/utilities/Swatch.tsx` |
diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts
index 1c86dc2828..cb581766a9 100644
--- a/frontend/packages/ui/src/index.ts
+++ b/frontend/packages/ui/src/index.ts
@@ -11,6 +11,8 @@ export {
export type { TypographyId } from "./lib/foundations/typography/typography";
export { Cta } from "./lib/product/Cta";
export type { CtaProps } from "./lib/product/Cta";
+export { Loader } from "./lib/product/Loader";
+export type { LoaderProps, LoaderTip } from "./lib/product/Loader";
export { Icon, iconIds } from "./lib/foundations/assets/Icon";
export type { IconId, IconProps } from "./lib/foundations/assets/Icon";
export { RawSvg, rawSvgIds } from "./lib/foundations/assets/RawSvg";
diff --git a/frontend/packages/ui/src/lib/product/Loader.module.scss b/frontend/packages/ui/src/lib/product/Loader.module.scss
new file mode 100644
index 0000000000..c39abe4056
--- /dev/null
+++ b/frontend/packages/ui/src/lib/product/Loader.module.scss
@@ -0,0 +1,81 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+@use "../_ds/_utils.scss" as *;
+
+@keyframes line-pencil {
+ 0% {
+ transform: translateY(0);
+ }
+
+ 100% {
+ transform: translateY(px2rem(-150));
+ }
+}
+
+.wrapper {
+ --loader-color-foreground: var(--color-foreground-secondary);
+
+ display: inline-flex;
+ column-gap: var(--sp-s);
+ align-items: center;
+ color: var(--loader-color-foreground);
+}
+
+.file-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ block-size: 100%;
+ inline-size: 100%;
+ padding: var(--sp-xxl);
+}
+
+.loader-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--sp-xl);
+}
+
+.tips-container {
+ text-align: center;
+ min-inline-size: px2rem(600);
+ max-inline-size: px2rem(800);
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-s);
+ border: 1px solid var(--color-background-tertiary);
+ border-radius: var(--sp-l);
+ padding: var(--sp-xxl) var(--sp-xxxl);
+}
+
+.tip-title {
+ color: var(--color-foreground-primary);
+ font-weight: 500;
+}
+
+.tip-message {
+ color: var(--color-foreground-secondary);
+}
+
+.wrapper-overlay {
+ display: grid;
+ place-content: center;
+ block-size: 100%;
+ inline-size: 100%;
+ row-gap: var(--sp-s);
+}
+
+.loader {
+ fill: currentcolor;
+ inline-size: var(--icon-width);
+}
+
+.loader-line {
+ animation: line-pencil 0.8s infinite linear;
+}
diff --git a/frontend/packages/ui/src/lib/product/Loader.spec.tsx b/frontend/packages/ui/src/lib/product/Loader.spec.tsx
new file mode 100644
index 0000000000..b4c766ee6f
--- /dev/null
+++ b/frontend/packages/ui/src/lib/product/Loader.spec.tsx
@@ -0,0 +1,90 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Loader } from "./Loader";
+
+describe("Loader", () => {
+ it("should render successfully", () => {
+ const { baseElement } = render();
+ expect(baseElement).toBeTruthy();
+ });
+
+ it("should render the SVG loader icon with role='status'", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg[role='status']");
+ expect(svg).toBeTruthy();
+ });
+
+ it("should apply the wrapper class by default", () => {
+ const { container } = render();
+ expect(container.firstElementChild?.getAttribute("class")).toContain(
+ "wrapper",
+ );
+ });
+
+ it("should apply wrapper-overlay class when overlay=true", () => {
+ const { container } = render();
+ expect(container.firstElementChild?.getAttribute("class")).toContain(
+ "wrapper-overlay",
+ );
+ });
+
+ it("should apply file-loading class when fileLoading=true", () => {
+ const { container } = render();
+ expect(container.firstElementChild?.getAttribute("class")).toContain(
+ "file-loading",
+ );
+ });
+
+ it("should use default width and height of 100x27 when none given", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg?.getAttribute("width")).toBe("100");
+ expect(svg?.getAttribute("height")).toBe("27");
+ });
+
+ it("should calculate height from width when only width is given", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg?.getAttribute("width")).toBe("200");
+ // height = ceil(200 * 27/100) = 54
+ expect(svg?.getAttribute("height")).toBe("54");
+ });
+
+ it("should calculate width from height when only height is given", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ // width = ceil(54 * 100/27) = 200
+ expect(svg?.getAttribute("width")).toBe("200");
+ expect(svg?.getAttribute("height")).toBe("54");
+ });
+
+ it("should use the provided title as the SVG
", () => {
+ const { container } = render();
+ const title = container.querySelector("svg title");
+ expect(title?.textContent).toBe("Please wait");
+ });
+
+ it("should render children", () => {
+ const { getByText } = render(Loading text);
+ expect(getByText("Loading text")).toBeTruthy();
+ });
+
+ it("should forward className to the root div", () => {
+ const { container } = render();
+ expect(container.firstElementChild?.getAttribute("class")).toContain(
+ "custom-loader",
+ );
+ });
+
+ it("should not show tips container initially without fileLoading", () => {
+ const { container } = render();
+ const tips = container.querySelector("[class*='tips-container']");
+ expect(tips).toBeNull();
+ });
+});
diff --git a/frontend/packages/ui/src/lib/product/Loader.stories.tsx b/frontend/packages/ui/src/lib/product/Loader.stories.tsx
new file mode 100644
index 0000000000..9075ce84fe
--- /dev/null
+++ b/frontend/packages/ui/src/lib/product/Loader.stories.tsx
@@ -0,0 +1,60 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Loader } from "./Loader";
+
+const meta = {
+ title: "Product/Loader",
+ component: Loader,
+ args: {
+ overlay: false,
+ fileLoading: false,
+ },
+ argTypes: {
+ title: { control: "text" },
+ width: { control: "number" },
+ height: { control: "number" },
+ overlay: { control: "boolean" },
+ fileLoading: { control: "boolean" },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const WithContent: Story = {
+ args: {
+ children: "Lorem ipsum",
+ },
+};
+
+export const Overlay: Story = {
+ args: {
+ overlay: true,
+ children: "Lorem ipsum",
+ },
+ parameters: {
+ layout: "fullscreen",
+ },
+};
+
+export const CustomSize: Story = {
+ args: {
+ width: 200,
+ },
+};
+
+export const FileLoading: Story = {
+ args: {
+ fileLoading: true,
+ },
+ parameters: {
+ layout: "fullscreen",
+ },
+};
diff --git a/frontend/packages/ui/src/lib/product/Loader.tsx b/frontend/packages/ui/src/lib/product/Loader.tsx
new file mode 100644
index 0000000000..d1c55711bb
--- /dev/null
+++ b/frontend/packages/ui/src/lib/product/Loader.tsx
@@ -0,0 +1,181 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import {
+ type ComponentPropsWithRef,
+ memo,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import clsx from "clsx";
+import styles from "./Loader.module.scss";
+
+// ---------------------------------------------------------------------------
+// SVG paths (from CLJS source)
+// ---------------------------------------------------------------------------
+
+const LOADER_PATH_1 =
+ "M128.273 0l-3.9 2.77L0 91.078l128.273 91.076 549.075-.006V.008L128.273 0zm20.852 30l498.223.006V152.15l-498.223.007V30zm-25 9.74v102.678l-49.033-34.813-.578-32.64 49.61-35.225z";
+
+const LOADER_PATH_2 = "M134.482 157.147v25l518.57.008.002-25-518.572-.008z";
+
+// ---------------------------------------------------------------------------
+// Tips (i18n keys — in TS we receive already-translated strings or defaults)
+// ---------------------------------------------------------------------------
+
+const DEFAULT_TIPS = [
+ {
+ title: "Did you know?",
+ message: "You can use components to reuse elements across your designs.",
+ },
+ {
+ title: "Pro tip",
+ message: "Hold Ctrl/Cmd while dragging to disable snapping temporarily.",
+ },
+ {
+ title: "Pro tip",
+ message: "Double-click on a group to enter it and select individual items.",
+ },
+ {
+ title: "Did you know?",
+ message: "You can inspect code properties directly in the viewer.",
+ },
+ {
+ title: "Pro tip",
+ message:
+ "Use pages to organise different versions or sections of your project.",
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Loader icon (internal, private)
+// ---------------------------------------------------------------------------
+
+interface LoaderIconProps {
+ width: number;
+ height: number;
+ title: string;
+}
+
+function LoaderIcon({ width, height, title }: LoaderIconProps) {
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Loader
+// ---------------------------------------------------------------------------
+
+export interface LoaderTip {
+ title: string;
+ message: string;
+}
+
+export interface LoaderProps extends ComponentPropsWithRef<"div"> {
+ /** Width of the loader icon in px. Calculated from height if not given. */
+ width?: number;
+ /** Height of the loader icon in px. Calculated from width if not given. */
+ height?: number;
+ /** Accessible title for the loader icon. Defaults to "Loading". */
+ title?: string;
+ /** When true, the loader covers the full parent area. */
+ overlay?: boolean;
+ /**
+ * When true, the loader enters "file loading" mode — full-screen centred
+ * layout with rotating tips shown every 4 seconds.
+ */
+ fileLoading?: boolean;
+ /** Custom tips to cycle through in fileLoading mode. Falls back to built-in tips. */
+ tips?: LoaderTip[];
+}
+
+function LoaderInner({
+ width: widthProp,
+ height: heightProp,
+ title = "Loading",
+ overlay = false,
+ fileLoading = false,
+ tips: tipsProp,
+ className,
+ children,
+ ...rest
+}: LoaderProps) {
+ // Mirror CLJS width/height mutual calculation
+ const width =
+ widthProp ??
+ (heightProp != null ? Math.ceil(heightProp * (100 / 27)) : 100);
+ const height =
+ heightProp ?? (widthProp != null ? Math.ceil(widthProp * (27 / 100)) : 27);
+
+ const tips = useMemo(() => tipsProp ?? DEFAULT_TIPS, [tipsProp]);
+
+ const [tip, setTip] = useState(null);
+ const tipsRef = useRef(tips);
+
+ useEffect(() => {
+ tipsRef.current = tips;
+ }, [tips]);
+
+ useEffect(() => {
+ if (!fileLoading) return;
+ // First tip after 1 s, then rotate every 4 s
+ const initialTimer = setTimeout(() => {
+ setTip(
+ tipsRef.current[Math.floor(Math.random() * tipsRef.current.length)],
+ );
+ }, 1000);
+ const interval = setInterval(() => {
+ setTip(
+ tipsRef.current[Math.floor(Math.random() * tipsRef.current.length)],
+ );
+ }, 4000);
+ return () => {
+ clearTimeout(initialTimer);
+ clearInterval(interval);
+ };
+ }, [fileLoading]);
+
+ const rootClass = clsx(
+ styles.wrapper,
+ {
+ [styles["wrapper-overlay"]]: overlay,
+ [styles["file-loading"]]: fileLoading,
+ },
+ className,
+ );
+
+ return (
+
+
+
+ {fileLoading && tip != null && (
+
+
{tip.title}
+
{tip.message}
+
+ )}
+
+ {children}
+
+ );
+}
+
+export const Loader = memo(LoaderInner);