# Arabic and RTL text

> Fabric.js RTL text with a cursor in the right place: ShapedIText and ShapedTextbox measure Arabic and ligatures as the browser draws them.

Source: https://fabricjs-document-engine.jscrate.dev/docs/guides/rtl-text
Last updated: 2026-10-02

Fabric.js measures each letter on its own, then adds the widths. But it draws a run of letters in one go, and the font joins them: Arabic letters take their connected forms, and "ffi" can become one ligature. In Fabric.js RTL text the drawn line is then shorter than the measured one, so the cursor position, the selection and clicks drift away from the letters ([fabric.js #4815](https://github.com/fabricjs/fabric.js/issues/4815)). In Arabic text at 40px the cursor can land two letters away.

## Try it

```tsx
"use client";

import { IText } from "fabric";
import { ShapedIText } from "fabricjs-document-engine/text";
import { TextCursorInputIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";

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

const ARABIC = "مرحبا بالعالم العربي الجميل";
const FIRST_SPACE = [...ARABIC].indexOf(" ");

export default function ShapedTextDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const textsRef = useRef<{ plain: IText; shaped: IText } | null>(null);
  const [active, setActive] = useState<"plain" | "shaped" | null>(null);

  useEffect(() => {
    if (!canvas) return;
    const common = {
      fontSize: 34,
      fontFamily: "sans-serif",
      direction: "rtl",
      textAlign: "right",
      originX: "right",
      originY: "top",
      left: PAGE.width - 24,
      cursorColor: "#e11d48",
      cursorWidth: 3,
    } as const;
    const plain = new IText(ARABIC, { ...common, top: 50, fill: "#71717a" });
    const shaped = new ShapedIText(ARABIC, {
      ...common,
      top: 170,
      fill: "#0d0d0d",
    });
    textsRef.current = { plain, shaped };
    canvas.add(plain, shaped);
    canvas.requestRenderAll();
    return () => {
      canvas.remove(plain, shaped);
    };
  }, [canvas]);

  function placeCursor(which: "plain" | "shaped") {
    const texts = textsRef.current;
    if (!canvas || !texts) return;
    texts.plain.exitEditing();
    texts.shaped.exitEditing();
    const text = texts[which];
    canvas.setActiveObject(text);
    text.enterEditing();
    text.setSelectionStart(FIRST_SPACE);
    text.setSelectionEnd(FIRST_SPACE);
    canvas.requestRenderAll();
    setActive(which);
  }

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

      <Toolbar>
        <ToolButton
          icon={TextCursorInputIcon}
          label={t("shaped.plain")}
          primary={active === "plain"}
          disabled={!canvas}
          onClick={() => placeCursor("plain")}
        />
        <ToolButton
          icon={TextCursorInputIcon}
          label={t("shaped.shaped")}
          primary={active === "shaped"}
          disabled={!canvas}
          onClick={() => placeCursor("shaped")}
        />
      </Toolbar>

      <Status tone={active === "plain" ? "error" : active ? "ok" : "idle"}>
        {active ? t(`shaped.${active}Result`) : t("shaped.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>
  );
}
```

Both lines hold the same Arabic text. The buttons put the cursor right after the first word.

## Use the shaped classes

```ts
import {
  ShapedIText,
  ShapedTextbox,
  textObjects,
} from "fabricjs-document-engine/text";

const engine = createDocumentEngine({ canvas, customObjects: textObjects });

canvas.add(
  new ShapedIText("مرحبا بالعالم", {
    direction: "rtl",
    textAlign: "right",
    fontSize: 36,
  })
);
```

`ShapedIText` and `ShapedTextbox` measure each position from the text as the browser shapes it, word by word, and keep the results in a cache. Typing, selection, clicks, copy and paste work as in IText and Textbox.

- Fabric.js RTL text uses `direction: "rtl"` as before.
- Ligatures in Latin fonts, such as "fi" and "ffi", get the same fix.
- Text with letter spacing (`charSpacing`), `justify`, or on a path is drawn letter by letter by Fabric, so it is already measured right and left as it is.

## In a BoundedTextbox

```ts
new BoundedTextbox(arabicText, { width: 260, direction: "rtl", shaping: true });
```

`shaping: true` also makes line breaks follow the joined letters. See [Textbox width](https://fabricjs-document-engine.jscrate.dev/docs/guides/textbox-width).

The measuring functions are in the [Text API](https://fabricjs-document-engine.jscrate.dev/docs/api/text-api), for your own Fabric.js RTL text classes.
