# Textbox width

> Fabric.js textbox auto width grows a Textbox to its longest word. BoundedTextbox keeps your width, breaks long words, and can clip, add an ellipsis or shrink.

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

A Fabric.js Textbox wraps text at spaces, but a word wider than the box is never broken. Fabric.js textbox auto width then widens the whole box to that word ([fabric.js #2376](https://github.com/fabricjs/fabric.js/issues/2376)). A long link, an email address or a line of Chinese or Japanese text without spaces pushes the box past your layout. `BoundedTextbox` keeps the width you set.

## Try it

```tsx
"use client";

import { Textbox } from "fabric";
import {
  BoundedTextbox,
  type TextOverflow,
} from "fabricjs-document-engine/text";
import { ScissorsIcon, ShrinkIcon, TextIcon } 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 TEXT =
  "Read https://fabricjs-document-engine.jscrate.dev/docs/guides/textbox-width before you place long links in a design.";

const OVERFLOWS: TextOverflow[] = ["visible", "clip", "ellipsis"];

export default function BoundedTextboxDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const plainRef = useRef<Textbox | null>(null);
  const boundedRef = useRef<BoundedTextbox | null>(null);
  const [width, setWidth] = useState(180);
  const [overflow, setOverflow] = useState<TextOverflow>("ellipsis");
  const [shrink, setShrink] = useState(false);
  const [sizes, setSizes] = useState({ plain: 0, bounded: 0, font: 18 });

  useEffect(() => {
    if (!canvas) return;
    const common = {
      top: 40,
      fontSize: 18,
      originX: "left",
      originY: "top",
    } as const;
    const plain = new Textbox(TEXT, {
      ...common,
      left: 16,
      width: 180,
      fill: "#71717a",
    });
    const bounded = new BoundedTextbox(TEXT, {
      ...common,
      left: 256,
      width: 180,
      maxHeight: 110,
      overflow: "ellipsis",
      fill: "#0d0d0d",
    });
    plainRef.current = plain;
    boundedRef.current = bounded;
    canvas.add(plain, bounded);
    canvas.requestRenderAll();
    return () => {
      canvas.remove(plain, bounded);
    };
  }, [canvas]);

  useEffect(() => {
    const plain = plainRef.current;
    const bounded = boundedRef.current;
    if (!canvas || !plain || !bounded) return;
    plain.set({ width });
    bounded.set({
      width,
      overflow,
      fit: shrink ? "shrink" : "none",
      minFontSize: 8,
    });
    canvas.requestRenderAll();
    setSizes({
      plain: Math.round(plain.width),
      bounded: Math.round(bounded.width),
      font: Math.round(bounded.fontSize * 2) / 2,
    });
  }, [canvas, width, overflow, shrink]);

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

      <label className="flex items-center gap-3 text-[13px]">
        <span className="shrink-0">{t("textbox.width", { width })}</span>
        <input
          type="range"
          min={60}
          max={210}
          value={width}
          onChange={(event) => setWidth(Number(event.target.value))}
          className="w-full accent-[#6f00ff]"
        />
      </label>

      <Toolbar>
        {OVERFLOWS.map((value) => (
          <ToolButton
            key={value}
            icon={value === "clip" ? ScissorsIcon : TextIcon}
            label={t(`textbox.${value}`)}
            primary={overflow === value}
            onClick={() => setOverflow(value)}
          />
        ))}
        <ToolButton
          icon={ShrinkIcon}
          label={t("textbox.shrink")}
          primary={shrink}
          onClick={() => setShrink((value) => !value)}
        />
      </Toolbar>

      <Status tone="ok">
        {t("textbox.status", {
          plain: sizes.plain,
          bounded: sizes.bounded,
          font: sizes.font,
        })}
      </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 grey text is a plain Fabric.js Textbox. The black one is a `BoundedTextbox` with the same text and width. Move the slider and compare their widths.

## Keep the width

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

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

canvas.add(
  new BoundedTextbox("A long link: https://example.com/a/very/long/path", {
    width: 240,
    fontSize: 20,
  })
);
```

- A word wider than the box goes on its own line and breaks between letters, the same word wrap as CSS `overflow-wrap: anywhere`.
- Emoji and letters with accents stay whole, because breaks fall between graphemes.
- Styles, the cursor and selection keep working across those breaks.
- The box is never narrower than its widest letter.
- `breakWords: "never"` turns the new word wrap off and brings back Fabric.js textbox auto width.

Register the class with `customObjects: textObjects`, so documents with a `BoundedTextbox` save and load. Without an engine, call `registerTextObjects()` once.

## Limit the height

Set `maxHeight` and choose what happens to the lines below it:

| `overflow` | What you see                                                                   |
| ---------- | ------------------------------------------------------------------------------ |
| `visible`  | Every line, as in a Textbox (the default).                                     |
| `clip`     | Text is cut at `maxHeight`.                                                    |
| `ellipsis` | Lines past `maxHeight` are hidden, and the last one ends with an ellipsis "…". |

```ts
new BoundedTextbox(longText, {
  width: 240,
  maxHeight: 120,
  overflow: "ellipsis",
});
```

The text itself does not change: copy, search and editing still see every word. While the user edits, the box clips instead of drawing the ellipsis, so the cursor is never covered. SVG and PDF export use the same clip.

## Shrink the text to fit

`fit: "shrink"` lowers the font size until the text fits `maxHeight`. It tries sizes in half points with a binary search, so a 48px title needs about seven layouts, not forty.

```ts
const title = new BoundedTextbox(headline, {
  width: 300,
  maxHeight: 90,
  fit: "shrink",
  minFontSize: 10,
  fontSize: 48,
});

console.log(title.fontSize, title.getBaseFontSize());
```

`fontSize` reads the size drawn, for example 31.5, and `getBaseFontSize()` the size you set, 48. The document saves the font size you set, not the shrunk one. When the text gets shorter again, it grows back up to that size. Letters with their own `fontSize` shrink by the same share.

## Text that joins letters

For Arabic text or fonts with ligatures, add `shaping: true` so line breaks and the cursor follow the joined letters. See [Arabic and RTL text](https://fabricjs-document-engine.jscrate.dev/docs/guides/rtl-text).

Every option is listed in the [Text API](https://fabricjs-document-engine.jscrate.dev/docs/api/text-api).
