# Typing on phones

> Fabric.js mobile support for typing: Android keyboards, autocorrect, suggestions and IME put letters and styles in the right place, and iOS does not zoom.

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

Fabric.js types through a hidden textarea. On a phone that works poorly ([fabric.js #6588](https://github.com/fabricjs/fabric.js/issues/6588)), so Fabric.js mobile support for text needs extra work:

- An Android keyboard sends key code 229 for every key, so Fabric cannot see which key was pressed.
- The keyboard changes text away from the cursor: autocorrect replaces the word before it, and a suggestion replaces the word being typed.
- A swipe on the space bar moves the cursor without any key event.
- iOS zooms the page when the textarea, with its 1px font, gets focus.

Fabric works out each edit from its own idea of the cursor, so after an autocorrect the letters, or their styles, land in the wrong place in an IText or Textbox.

## Try it

```tsx
"use client";

import { type Canvas, IText } from "fabric";
import {
  attachMobileTextInput,
  type MobileTextInput,
} from "fabricjs-document-engine/text";
import { RotateCcwIcon, SmartphoneIcon, SpellCheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";

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

const BOLD = { fontWeight: "bold", fill: "#6f00ff" } as const;

function makeText() {
  return new IText("im here", {
    left: 40,
    top: 110,
    originX: "left",
    originY: "top",
    fontSize: 44,
    fill: "#0d0d0d",
    styles: { 0: { 3: BOLD, 4: BOLD, 5: BOLD, 6: BOLD } },
  });
}

function boldLetters(text: IText) {
  return [...text.text]
    .filter((_, index) => text.styles[0]?.[index]?.fontWeight === "bold")
    .join("");
}

export default function MobileInputDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const adapterRef = useRef<MobileTextInput | null>(null);
  const textRef = useRef<IText | null>(null);
  const [adapter, setAdapter] = useState(true);
  const [result, setResult] = useState<{ text: string; bold: string } | null>(
    null
  );

  function reset(target: Canvas) {
    if (textRef.current) target.remove(textRef.current);
    const text = makeText();
    textRef.current = text;
    target.add(text);
    target.requestRenderAll();
    setResult(null);
  }

  useEffect(() => {
    if (!canvas) return;
    if (adapter) adapterRef.current = attachMobileTextInput(canvas);
    reset(canvas);
    return () => {
      adapterRef.current?.detach();
      adapterRef.current = null;
    };
  }, [canvas, adapter]);

  function autocorrect() {
    const text = textRef.current;
    if (!canvas || !text) return;
    canvas.setActiveObject(text);
    text.enterEditing();
    text.setSelectionStart(text.text.length);
    text.setSelectionEnd(text.text.length);
    const textarea = text.hiddenTextarea;
    if (!textarea) return;
    textarea.dispatchEvent(
      new KeyboardEvent("keydown", {
        keyCode: 229,
        key: "Unidentified",
      } as KeyboardEventInit)
    );
    textarea.value = "I'm here";
    textarea.setSelectionRange(8, 8);
    textarea.dispatchEvent(
      new InputEvent("input", {
        inputType: "insertReplacementText",
        bubbles: true,
      })
    );
    text.exitEditing();
    canvas.requestRenderAll();
    setResult({ text: text.text, bold: boldLetters(text) });
  }

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

      <Toolbar>
        <ToolButton
          icon={SmartphoneIcon}
          label={adapter ? t("mobile.on") : t("mobile.off")}
          primary={adapter}
          onClick={() => setAdapter((value) => !value)}
        />
        <ToolButton
          icon={SpellCheckIcon}
          label={t("mobile.autocorrect")}
          disabled={!canvas}
          onClick={autocorrect}
        />
        <ToolButton
          icon={RotateCcwIcon}
          label={t("mobile.reset")}
          disabled={!canvas}
          onClick={() => canvas && reset(canvas)}
        />
      </Toolbar>

      <Status tone={!result ? "idle" : result.bold === "here" ? "ok" : "error"}>
        {result
          ? t("mobile.result", { text: result.text, bold: result.bold || "—" })
          : t("mobile.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>
  );
}
```

The button does what an Android keyboard does when it fixes "im" to "I'm": it changes the text before the cursor. With the adapter off, the bold style moves onto the wrong letters.

## Turn it on

```ts
import { attachMobileTextInput } from "fabricjs-document-engine/text";

const mobileInput = attachMobileTextInput(canvas);

mobileInput.detach();
```

Attach it once, before the user starts editing. It works for every IText, Textbox and their subclasses on the canvas, including ones added later.

- Each edit is read from the text itself: the shared start and end of the old and new text leave exactly what changed. Styles move with their letters.
- The cursor follows the textarea, including moves the Android keyboard makes on its own.
- The textarea gets focus inside the tap, so the keyboard opens on iOS and Android.
- The textarea uses a 16px font, so iOS does not zoom.
- When the keyboard opens and the page scrolls, the textarea stays at the cursor, so the input method shows its suggestions in the right place.

`detach()` puts Fabric's own handling back.

## Keyboard options

```ts
attachMobileTextInput(canvas, {
  inputMode: "text",
  enterKeyHint: "done",
  autocapitalize: "sentences",
  autocorrect: true,
});
```

| Option           | Default       |
| ---------------- | ------------- |
| `inputMode`      | `"text"`      |
| `enterKeyHint`   | `"enter"`     |
| `autocapitalize` | `"sentences"` |
| `autocorrect`    | `true`        |
| `spellcheck`     | `false`       |

Fabric turns autocorrect off because its own handling cannot apply corrections. With the adapter, autocorrect is safe to turn back on. For edits made from code, see [Text styles from code](https://fabricjs-document-engine.jscrate.dev/docs/guides/text-styles). Every type is in the [Text API](https://fabricjs-document-engine.jscrate.dev/docs/api/text-api), and this completes Fabric.js mobile support for text.
