# Copy, paste and layers

> Fabric.js copy paste that keeps groups and custom properties and gives every copy a new id, plus bring to front and send to back as one undo step.

Source: https://fabricjs-document-engine.jscrate.dev/docs/guides/copy-paste-layers
Last updated: 2026-10-01

Fabric.js copy paste usually means calling `clone` on an object. A clone object keeps the original's id, objects copied from a moved or rotated selection can land in the wrong place, and every step needs its own undo handling. The engine's clipboard and layer commands handle all three.

```tsx
"use client";

import { type Canvas, type FabricObject, Rect } from "fabric";
import {
  bringForward,
  bringToFront,
  createClipboard,
  type LayerOptions,
  sendBackward,
  sendToBack,
} from "fabricjs-document-engine";
import { useDocumentEngine, useLayers } from "fabricjs-document-engine/react";
import {
  ArrowDownIcon,
  ArrowDownToLineIcon,
  ArrowUpIcon,
  ArrowUpToLineIcon,
  ClipboardPasteIcon,
  CopyIcon,
  ScissorsIcon,
  Undo2Icon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";

import { cn } from "@/lib/utils";

import {
  DemoCanvas,
  DemoFrame,
  seedShapes,
  Status,
  Toolbar,
  ToolButton,
} from "./demo-ui";
import { PAGE, useFabricCanvas } from "./use-fabric-canvas";

// The background stays at the bottom, whatever the layer buttons do.
const KEEP_BACKGROUND: LayerOptions = {
  pinned: (object) =>
    (object as FabricObject & { name?: string }).name === "background",
};

const NAMES = ["card", "sun", "pill", "triangle"];

export default function ClipboardLayersDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const engine = useDocumentEngine(canvas);
  const clipboard = useMemo(
    () => (engine ? createClipboard(engine) : null),
    [engine]
  );
  const layers = useLayers(engine);
  const [message, setMessage] = useState("");

  useEffect(() => {
    if (!engine) return;
    const background = new Rect({
      originX: "left",
      originY: "top",
      left: 0,
      top: 0,
      width: PAGE.width,
      height: PAGE.height,
      fill: "#f4f4f5",
      selectable: false,
      evented: false,
    });
    const shapes = seedShapes();
    [background, ...shapes].forEach((object, index) =>
      object.set("name", index === 0 ? "background" : NAMES[index - 1])
    );
    engine.canvas.add(background, ...shapes);
    engine.clearHistory();
  }, [engine]);

  function selected(): FabricObject[] {
    return (engine?.canvas as Canvas | undefined)?.getActiveObjects() ?? [];
  }

  function run(label: string, work: () => boolean | number | Promise<unknown>) {
    if (!engine) return;
    if (selected().length === 0 && label !== "paste" && label !== "undo") {
      setMessage(t("layers.selectFirst"));
      return;
    }
    void Promise.resolve(work()).then(() =>
      setMessage(t(`layers.done.${label}`))
    );
  }

  function select(id: string | undefined) {
    const object = id ? engine?.getObjectById(id) : undefined;
    const interactive = engine?.canvas as Canvas | undefined;
    if (!object || !interactive || object.selectable === false) return;
    interactive.setActiveObject(object);
    interactive.requestRenderAll();
  }

  return (
    <DemoFrame>
      <DemoCanvas elementRef={elementRef} label={t("canvas")} />

      <Toolbar>
        <ToolButton
          icon={CopyIcon}
          label={t("layers.copy")}
          iconOnly
          disabled={!engine}
          onClick={() => run("copy", () => clipboard!.copy())}
        />
        <ToolButton
          icon={ScissorsIcon}
          label={t("layers.cut")}
          iconOnly
          disabled={!engine}
          onClick={() => run("cut", () => clipboard!.cut())}
        />
        <ToolButton
          icon={ClipboardPasteIcon}
          label={t("layers.paste")}
          iconOnly
          disabled={!engine}
          onClick={() => run("paste", () => clipboard!.paste())}
        />
        <span aria-hidden className="mx-1 h-5 w-px bg-border" />
        <ToolButton
          icon={ArrowUpToLineIcon}
          label={t("layers.front")}
          iconOnly
          disabled={!engine}
          onClick={() =>
            run("front", () =>
              bringToFront(engine!, undefined, KEEP_BACKGROUND)
            )
          }
        />
        <ToolButton
          icon={ArrowUpIcon}
          label={t("layers.forward")}
          iconOnly
          disabled={!engine}
          onClick={() =>
            run("forward", () =>
              bringForward(engine!, undefined, KEEP_BACKGROUND)
            )
          }
        />
        <ToolButton
          icon={ArrowDownIcon}
          label={t("layers.backward")}
          iconOnly
          disabled={!engine}
          onClick={() =>
            run("backward", () =>
              sendBackward(engine!, undefined, KEEP_BACKGROUND)
            )
          }
        />
        <ToolButton
          icon={ArrowDownToLineIcon}
          label={t("layers.back")}
          iconOnly
          disabled={!engine}
          onClick={() =>
            run("back", () => sendToBack(engine!, undefined, KEEP_BACKGROUND))
          }
        />
        <span aria-hidden className="mx-1 h-5 w-px bg-border" />
        <ToolButton
          icon={Undo2Icon}
          label={t("undo")}
          iconOnly
          disabled={!engine}
          onClick={() => run("undo", () => engine!.undo())}
        />
      </Toolbar>

      <ol
        aria-label={t("layers.list")}
        className="flex flex-wrap gap-1.5 text-[12px]"
      >
        {layers.map((layer) => (
          <li key={layer.id}>
            <button
              type="button"
              disabled={layer.locked}
              onClick={() => select(layer.id)}
              className={cn(
                "rounded-md border px-2 py-0.5 transition-colors hover:bg-muted disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent"
              )}
            >
              {layer.name ?? layer.type}
              {layer.locked ? ` · ${t("layers.pinned")}` : ""}
            </button>
          </li>
        ))}
      </ol>

      <Status tone={message ? "ok" : "idle"}>
        {message || t("layers.hint")}
      </Status>
    </DemoFrame>
  );
}
```

```tsx
import { Canvas } from "fabric";
import { useLayoutEffect, useRef, useState } from "react";

/**
 * The page every demo draws on. The document keeps this size; CSS scales the
 * canvas down to fit its card, so saved documents and exports never depend on
 * the screen.
 */
export const PAGE = { width: 480, height: 300 };

// Selection handles in the site's style. Fabric does not save these.
const SELECTION = {
  borderColor: "#6f00ff",
  borderScaleFactor: 1.5,
  borderOpacityWhenMoving: 0.5,
  cornerStyle: "circle",
  cornerSize: 11,
  cornerColor: "#ffffff",
  cornerStrokeColor: "#6f00ff",
  transparentCorners: false,
} as const;

export function styleSelection(canvas: Canvas) {
  canvas.set({
    selectionColor: "rgba(111, 0, 255, 0.06)",
    selectionBorderColor: "#6f00ff",
    selectionLineWidth: 1,
  });
  const style = () => canvas.getActiveObject()?.set(SELECTION);
  canvas.on("selection:created", style);
  canvas.on("selection:updated", style);
}

/**
 * Creates a Fabric.js canvas once the `<canvas>` element exists, and disposes
 * it on unmount. The engine is created from the returned `canvas`.
 */
export function useFabricCanvas({ width, height } = PAGE) {
  const elementRef = useRef<HTMLCanvasElement>(null);
  const [canvas, setCanvas] = useState<Canvas | null>(null);

  // A layout effect creates the canvas before the first paint.
  useLayoutEffect(() => {
    const created = new Canvas(elementRef.current!, {
      width,
      height,
      backgroundColor: "#ffffff",
    });
    styleSelection(created);
    setCanvas(created);

    return () => {
      // Dispose after the engine's own cleanup, so its last recovery copy
      // still sees the objects.
      setTimeout(() => created.dispose().catch(() => undefined));
    };
  }, [width, height]);

  return { elementRef, canvas };
}
```

```tsx
import { Circle, type FabricObject, Rect, Triangle } from "fabric";
import type { LucideIcon } from "lucide-react";

import { cn } from "@/lib/utils";

import { PAGE } from "./use-fabric-canvas";

export const COLORS = ["#f2836f", "#5fe6c4", "#7ad9ec", "#6f00ff", "#ffd666"];

// Demo shapes are placed by their top-left corner, like a design tool.
const CORNER = { originX: "left", originY: "top" } as const;

/** A new shape at a random spot on the page. */
export function newShape(kind: "square" | "circle", fill?: string) {
  const left = 40 + Math.random() * (PAGE.width - 160);
  const top = 30 + Math.random() * (PAGE.height - 120);
  const color = fill ?? COLORS[Math.floor(Math.random() * COLORS.length)];
  return kind === "square"
    ? new Rect({
        ...CORNER,
        left,
        top,
        width: 88,
        height: 64,
        rx: 10,
        ry: 10,
        fill: color,
      })
    : new Circle({ ...CORNER, left, top, radius: 34, fill: color });
}

/** A small composed drawing, so no demo starts on an empty page. */
export function seedShapes(): FabricObject[] {
  return [
    new Rect({
      ...CORNER,
      left: 48,
      top: 56,
      width: 176,
      height: 124,
      rx: 16,
      ry: 16,
      fill: COLORS[4],
    }),
    new Circle({ ...CORNER, left: 268, top: 44, radius: 58, fill: COLORS[0] }),
    new Rect({
      ...CORNER,
      left: 256,
      top: 196,
      width: 176,
      height: 52,
      rx: 26,
      ry: 26,
      fill: COLORS[1],
    }),
    new Triangle({
      ...CORNER,
      left: 96,
      top: 196,
      width: 72,
      height: 60,
      fill: COLORS[3],
    }),
  ];
}

export function DemoFrame({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      className={cn(
        "flex w-full max-w-md flex-col gap-3 text-left outline-none",
        className
      )}
      {...props}
    />
  );
}

/**
 * Scales the fixed-size Fabric canvas to the frame's width. The rules are
 * `!important` because Fabric writes its sizes as inline styles.
 */
export function DemoCanvas({
  elementRef,
  label,
  page = PAGE,
  children,
}: {
  elementRef: React.Ref<HTMLCanvasElement>;
  label: string;
  page?: { width: number; height: number };
  children?: React.ReactNode;
}) {
  return (
    <div
      className="relative overflow-hidden rounded-lg border bg-white shadow-xs [&_.canvas-container]:aspect-(--page)! [&_.canvas-container]:h-auto! [&_.canvas-container]:w-full! [&_canvas]:h-full! [&_canvas]:w-full!"
      style={
        { "--page": `${page.width} / ${page.height}` } as React.CSSProperties
      }
    >
      <div>
        <canvas ref={elementRef} aria-label={label} />
      </div>
      {children}
    </div>
  );
}

export function Toolbar({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      className={cn("flex flex-wrap items-center gap-1.5", className)}
      {...props}
    />
  );
}

export function ToolButton({
  icon: Icon,
  label,
  iconOnly = false,
  primary = false,
  className,
  ...props
}: React.ComponentProps<"button"> & {
  icon: LucideIcon;
  label: string;
  iconOnly?: boolean;
  primary?: boolean;
}) {
  return (
    <button
      type="button"
      title={label}
      aria-label={iconOnly ? label : undefined}
      className={cn(
        "inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md border px-2.5 text-[13px] font-medium whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0",
        primary
          ? "border-transparent bg-foreground text-background hover:bg-foreground/85"
          : "bg-background hover:bg-muted",
        iconOnly && "w-8 px-0",
        className
      )}
      {...props}
    >
      <Icon aria-hidden />
      {!iconOnly && <span className="truncate">{label}</span>}
    </button>
  );
}

const TONES = {
  idle: "bg-zinc-400",
  busy: "bg-amber-400",
  ok: "bg-emerald-500",
  error: "bg-red-500",
};

/** One line of status text. Its height never changes, so the card never jumps. */
export function Status({
  tone = "idle",
  children,
}: {
  tone?: keyof typeof TONES;
  children: React.ReactNode;
}) {
  return (
    <p
      role="status"
      className="flex h-5 min-w-0 items-center gap-2 text-[13px] text-muted-foreground"
    >
      <span
        aria-hidden
        className={cn("size-1.5 shrink-0 rounded-full", TONES[tone])}
      />
      <span className="truncate">{children}</span>
    </p>
  );
}
```

## Copy, cut and paste

```ts
import { createClipboard } from "fabricjs-document-engine";

const clipboard = createClipboard(engine, { offset: 10 });

clipboard.copy();          // the selection, or pass objects
await clipboard.paste();   // one undo step
clipboard.cut();           // one undo step; the next paste lands in place
await clipboard.paste({ target: otherEngine });
```

- Every pasted object, group child and clip path gets a new id, so a duplicate object never clashes with the original.
- Objects copied from a moved, rotated or scaled selection keep the position they have on the canvas.
- Custom properties of registered classes are kept.
- Each paste moves `offset` further, so repeated pastes do not stack on top of each other.

## Copy between tabs

The Fabric.js copy paste content is plain JSON, so it can travel through the system clipboard:

```ts
await navigator.clipboard.writeText(JSON.stringify(clipboard.read()));
clipboard.write(JSON.parse(await navigator.clipboard.readText()));
```

`write` checks the JSON like a loaded document, so pasted content cannot bring in unsafe image addresses. An object type the engine has not registered rejects with `UNKNOWN_OBJECT_TYPE`.

## Bring to front and other layer commands

```ts
import { bringForward, bringToFront, sendBackward, sendToBack } from "fabricjs-document-engine";

const keepBackground = { pinned: (object) => object.name === "background" };

bringToFront(engine);
sendToBack(engine, undefined, keepBackground);
bringForward(engine, [logo]);
```

- Each command moves the objects you pass, or the selection, and records one undo step.
- Several selected objects keep their order, and neighbours move as a block.
- A pinned object, such as a background or a frame, never moves, and other objects never pass it.
- A command that would change nothing returns `false` and records nothing.

## A layers panel

`getLayers(engine)` lists the objects from top to bottom as `{ id, type, name, index, visible, locked }`. In React, `useLayers(engine)` returns the same list and updates after every change:

```tsx
import { useLayers } from "fabricjs-document-engine/react";

function LayersPanel({ engine }) {
  const layers = useLayers(engine);
  return layers.map((layer) => <div key={layer.id}>{layer.name ?? layer.type}</div>);
}
```

The engine saves each object's `name`, so layers keep their labels after reopening. Every command is listed with the [helpers](https://fabricjs-document-engine.jscrate.dev/docs/api/helpers), and [undo and redo](https://fabricjs-document-engine.jscrate.dev/docs/guides/undo-redo) explains how steps are recorded.
