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). 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
Loading the live demo. Its code is below.
"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>
);
}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
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 "…". |
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.
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.
Every option is listed in the Text API.