Docs/Introduction

View source on GitHub

Introduction

What is semantic-wrap?

A language-independent JavaScript library that selects line breaks from a trained model and the actual rendered layout. It inserts <br> only when the calculated result is better.

Both the phrase model and the selection strategy are replaceable. Experimental presets are available for English and Korean titles, while Core remains independent of any language, rendering environment, or UI framework.

ESM onlyReact 19+Node.js 22+

01

Examples

Compare CSS balance, which considers width alone, with results that preserve model-predicted phrase boundaries.

CSS balance and semantic-wrap results
CSS balancesemantic-wrap
Write clear headlines for
readers, not for reviewers
Write clear headlines
for readers, not for reviewers
Earn customer trust before
asking for more data
Earn customer trust
before asking for more data
The best design systems create consistency
without blocking local needs
The best design systems
create consistency without blocking local needs
Design documentation for
people who need to act
Design documentation
for people who need to act

02

Quick start

Install

Install Core, the React adapter, and the English preset to use semantic-wrap in React.

Terminal
npm install @semantic-wrap/core @semantic-wrap/react @semantic-wrap/en react react-dom

@semantic-wrap/react requires React and React DOM 19 or later. Core-only and model-only projects do not need React. All packages are ESM-only.

Use it in React

Title.tsx
import { enTitleModel } from "@semantic-wrap/en";
import { SemanticWrap } from "@semantic-wrap/react";

export function Title({ children }: { children: string }) {
  return (
    <SemanticWrap model={enTitleModel}>
      <h1 className="title">{children}</h1>
    </SemanticWrap>
  );
}

SemanticWrap preserves its child element and adds no wrapper. First display (initial) and resize scheduling (resize) are independent. Defaults resolved + immediate preserve exact-first display and synchronous updates. Native + settled shows source first, calculates automatically in cooperative slices, and applies resize results at a stable width. All four combinations preserve exact selection.

03

How it works

  1. 01
    Predict and aggregate

    A phrase model predicts candidate boundaries and assigns a cost to each one.

  2. 02
    Calculate layouts

    Core measures multiple candidates with the actual font and available width.

  3. 03
    Verify in the browser

    A fitting native layout is replaced only when the model provides stronger evidence.

  4. 04
    Select and render

    Visual balance chooses the final result; React renders <br> only when a calculated layout wins.

The React package measures again when the element resizes, its class or inline style changes, or web fonts finish loading.

04

Packages

semantic-wrap packages and responsibilities
PackagePurpose
@semantic-wrap/coreBoundary prediction, candidate aggregation, layout calculation, and selection
@semantic-wrap/reactDOM measurement and <br> rendering for React
@semantic-wrap/enExperimental phrase model for English titles
@semantic-wrap/koExperimental phrase model for Korean titles

Core 01

selectLineBreaks

selectLineBreaks runs the complete prediction-to-selection pipeline without depending on React or the DOM.

line-breaks.ts
import { selectLineBreaks } from "@semantic-wrap/core";
import { enTitleModel } from "@semantic-wrap/en";

const canvas = document.createElement("canvas");
const canvasContext = canvas.getContext("2d")!;
canvasContext.font = "700 28px system-ui";

const result = selectLineBreaks({
  text: "Write clear headlines for readers, not for reviewers",
  model: enTitleModel,
  maxWidth: 420,
  measureText: (text) => canvasContext.measureText(text).width,
});

console.log(result.lines);
// ["Write clear headlines", "for readers, not for reviewers"]

Required input

selectLineBreaks input
FieldTypeDescription
textstringSource text to wrap
modelPhraseModelModel that predicts boundaries and priorities
maxWidthnumberMaximum width available to one line
measureText(text: string) => numberMeasures a string with the target font

Options

selectLineBreaks options
FieldTypeDefaultDescription
nativeLayoutBaselineLayoutnoneExisting line breaks as ascending UTF-16 offsets
strategyLineBreakStrategydefault strategyOverrides aggregation, calculation, or selection
diagnosticsbooleanfalseIncludes intermediate pipeline results

When nativeLayout is present, Core evaluates it with the calculated candidates. The default selector may replace an overflowing native layout with any fitting result. Otherwise, replacement requires the same line count and a lower modelCost.

Output: LineBreakSelection

LineBreakSelection output
FieldTypeDescription
textstringOriginal input text
linesstring[]Text split at selected boundaries
breaksnumber[]Ascending UTF-16 offsets for line ends
widthsnumber[]Measured width of each line
selectedCandidatesBreakCandidate[]Model candidates used by the selected layout
appliedbooleanWhether calculated breaks should render
reasonstringReason returned by selection
overflowbooleanWhether a selected line exceeds maxWidth
diagnosticsLineBreakDiagnosticsPresent only when diagnostics are enabled

Core 02

createLineBreakPlan

Create a lazy plan when the same text, model, and strategy will be measured at multiple widths.

line-break-plan.ts
const plan = createLineBreakPlan({ text, model, strategy });

plan.predict();
plan.aggregate();
plan.calculate({ maxWidth, measureText });
plan.select({ maxWidth, measureText, nativeLayout });

Calling a later stage runs its prerequisites. Prediction and aggregation are cached as immutable snapshots; calculation and selection run for every measurement.

Core 03

Custom phrase models

Each PhraseModel level provides one synchronous predictor. Predictions from multiple levels can be aggregated together.

PhraseModel fields
FieldRequiredDefaultDescription
levelsyesOne or more predictors and their relative penalties
fallbackPenaltyyesCost of an allowed boundary not predicted by a level
boundaryModenospacesWhitespace or Unicode grapheme boundaries

Lower penalties are preferred. When multiple levels predict the same boundary, the default aggregation stage keeps the lowest penalty.

colon-model.ts
import {
  createBudouxPredictor,
  definePhraseModel,
  selectLineBreaks,
} from "@semantic-wrap/core";

const canvasContext = document.createElement("canvas").getContext("2d")!;
canvasContext.font = "700 28px system-ui";

const colonTitleModel = definePhraseModel({
  boundaryMode: "spaces",
  levels: [{
    name: "after-colon",
    predictor: createBudouxPredictor({ UW3: { ":": 100 } }),
    penalty: 0,
  }],
  fallbackPenalty: 1,
});

const result = selectLineBreaks({
  text: "Design review checklist: what to ask before approval",
  model: colonTitleModel,
  maxWidth: 400,
  measureText: (value) => canvasContext.measureText(value).width,
});

console.log(result.lines);
// ["Design review checklist:", "what to ask before approval"]

definePhraseModel validates and freezes model configuration. createBudouxPredictor adapts BudouX weights to the generic predictor contract.

Implement a predictor

A BoundaryPredictor returns strictly ascending UTF-16 source offsets inside the text. The configured boundaryMode filters them to valid wrap positions.

editorial-model.ts
import { definePhraseModel } from "@semantic-wrap/core";

const editorialModel = definePhraseModel({
  boundaryMode: "spaces",
  levels: [
    {
      name: "after-colon",
      predictor: {
        predict: (text) =>
          [...text.matchAll(/:\s/gu)].map((match) => match.index + 1),
      },
      penalty: 0,
    },
  ],
  fallbackPenalty: 1,
});

UW3 is the BudouX feature for the character immediately before a boundary. The value 100 is a feature weight, not a probability.

Core 04

Strategies

The default strategy has three independently replaceable stages.

Strategy stages
StageDefaultCustomization examples
aggregatelowestPenalty()Require model agreement with consensus()
calculateoptimalLayouts()Use greedy() or custom line-count rules
selectbalance()Apply product-specific scores and replacement rules
strategy.ts
import {
  balance,
  consensus,
  createLineBreakStrategy,
  greedy,
} from "@semantic-wrap/core";

const consensusStrategy = createLineBreakStrategy({
  aggregate: consensus({ minimumModels: 2 }),
  select: balance({ tolerance: 0.12 }),
});

const greedyStrategy = createLineBreakStrategy({
  calculate: greedy(),
});

optimalLayouts() returns non-dominated, minimum-line candidates across visual balance and model cost. balance() uses a default tolerance of 0.12, requires lower model cost before replacing a fitting native layout, and allows any fitting candidate when native overflows.

Replace the calculation stage

This example still creates a two-line title but rejects candidates that leave one word on the last line.

two-line-title.ts
import {
  createLineBreakStrategy,
  selectLineBreaks,
  type LineBreakCalculator,
} from "@semantic-wrap/core";
import { enTitleModel } from "@semantic-wrap/en";

const canvasContext = document.createElement("canvas").getContext("2d")!;
canvasContext.font = "700 28px system-ui";

const twoLineTitleCalculator: LineBreakCalculator = ({
  text,
  candidates,
  maxWidth,
  measureText,
}) => {
  let best: { offset: number; score: number } | undefined;

  for (const candidate of candidates) {
    const firstLine = text.slice(0, candidate.offset).trimEnd();
    const lastLine = text.slice(candidate.offset).trimStart();
    const firstWidth = measureText(firstLine);
    const lastWidth = measureText(lastLine);

    if (firstWidth > maxWidth || lastWidth > maxWidth) continue;
    if (lastLine.split(/\s+/u).length < 2) continue;

    const imbalance = Math.abs(firstWidth - lastWidth) / maxWidth;
    const score = candidate.penalty + imbalance;
    if (!best || score < best.score) {
      best = { offset: candidate.offset, score };
    }
  }

  return [{ breaks: best ? [best.offset] : [] }];
};

const twoLineTitleStrategy = createLineBreakStrategy({
  calculate: twoLineTitleCalculator,
});

const input = {
  text: "Good metrics guide decisions before they become dashboard decoration",
  model: enTitleModel,
  maxWidth: 360,
  measureText: (value: string) => canvasContext.measureText(value).width,
};

console.log(selectLineBreaks(input).lines);
// ["Good metrics guide decisions before", "they become dashboard decoration"]

console.log(selectLineBreaks(input, { strategy: twoLineTitleStrategy }).lines);
// ["Good metrics guide decisions", "before they become dashboard decoration"]

Core 05

Diagnostics

Enable diagnostics when tuning aggregation rules or investigating a result.

diagnostics.ts
const result = selectLineBreaks(input, { diagnostics: true });

console.log(result.diagnostics.predictions);
console.log(result.diagnostics.candidates);
Diagnostics fields
FieldDescription
predictionsRaw boundaries predicted by each model level
candidatesOne candidate list produced by aggregate
calculatedLayoutsMeasured candidates with line count, balance, model cost, and overflow
nativeLayoutMeasured browser layout when supplied
selectionSource, index, and reason returned by select

The default selector returns native-no-model-improvement when no calculated layout lowers model cost. Other defaults are native-selected and calculated-selected.

React 01

<SemanticWrap />

Wrap one plain-text React element that forwards its ref to an actual HTMLElement. The component measures the rendered font and width, then applies the Core selection without adding another element.

SemanticWrap props
PropRequiredDefaultDescription
childrenyesOne plain-text React element
modelyesPhrase model used to create candidates
strategynodefault strategyAggregation, calculation, and selection rules
initialnoresolvedExact-first display; native shows source before automatic calculation
resizenoimmediateSynchronous updates; settled applies completed work after about 100 ms of stable width
modenoDeprecated precise/progressive compatibility; cannot mix with new options
refnoHTMLElement ref shared with the child
scheduling.tsx
<SemanticWrap initial="native" resize="settled" model={enTitleModel}>
  <h1>{title}</h1>
</SemanticWrap>

All combinations measure in an invisible DOM copy. Native-first starts automatically without a resize. Settled updates show source while calculating and apply the latest result after about 100 ms of stable width and completed work; completion within 100 ms is not guaranteed. Legacy mode is deprecated: precise means resolved + immediate, while progressive retains first-resize activation.

At unchanged text and measurement conditions, new model/strategy references retain the displayed result while revalidating. Only changed results are published. Stable references avoid redundant calculation but are not required for correctness.

React 02

Chakra UI

Components that forward their ref to a real HTMLElement work in the same way.

ChakraTitle.tsx
import { Text } from "@chakra-ui/react";
import { enTitleModel } from "@semantic-wrap/en";
import { SemanticWrap } from "@semantic-wrap/react";

<SemanticWrap model={enTitleModel}>
  <Text textStyle="heading2">{title}</Text>
</SemanticWrap>

React 03

Tailwind CSS

Classes on a plain-text element remain unchanged.

TailwindTitle.tsx
import { createLineBreakStrategy, greedy } from "@semantic-wrap/core";
import { enTitleModel } from "@semantic-wrap/en";
import { SemanticWrap } from "@semantic-wrap/react";

const greedyStrategy = createLineBreakStrategy({
  calculate: greedy(),
});

<SemanticWrap model={enTitleModel} strategy={greedyStrategy}>
  <h2 className="text-3xl font-bold leading-tight">{title}</h2>
</SemanticWrap>

React 04

useSemanticWrap

Use the lower-level hook to render selected lines yourself or inspect diagnostics. It measures with the target element's computed text style and does not alter the target's children or CSS.

useSemanticWrap options
FieldRequiredDefaultDescription
textyesSource text to measure and split
modelyesPhrase model used to create candidates
strategynodefault strategyAggregation, calculation, and selection rules
diagnosticsnofalseWhether to return intermediate results
initialnoresolvedSynchronous initial calculation, or automatic native-first work
resizenoimmediateSynchronous updates, or cooperative settled updates
BreakPreview.tsx
import { enTitleModel } from "@semantic-wrap/en";
import { useSemanticWrap } from "@semantic-wrap/react";

export function BreakPreview({ title }: { title: string }) {
  const { ref, selection } = useSemanticWrap({
    text: title,
    model: enTitleModel,
  });
  const preview = selection ? selection.lines.join(" / ") : title;

  return <h1 ref={ref}>{preview}</h1>;
}

Output: UseSemanticWrapResult

useSemanticWrap output
FieldTypeDescription
ref(HTMLElement | null) => voidCallback ref for the measured element
selectionLineBreakSelection | nullNull before measurement or during pending text/geometry work; retains the previous result during reference-only revalidation
diagnosticsLineBreakDiagnostics | nullDiagnostics after measurement when requested

Models

English and Korean presets

Use enTitleModel for English titles and koTitleModel for Korean titles. Both models create candidates only at whitespace boundaries.

models.ts
import { koTitleModel } from "@semantic-wrap/ko";
import { enTitleModel } from "@semantic-wrap/en";

Project 01

Development

Terminal
bun install
bun run check

bun run check runs type checking, unit tests, the build, Chromium, Firefox, and WebKit browser tests, and npm package validation.

Project 02

License

Apache-2.0. @semantic-wrap/core includes a modified, dependency-free implementation of the Google BudouX parser. See NOTICE for details.