Docs/Introduction
View source on GitHubIntroduction
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.
01
Examples
Compare CSS balance, which considers width alone, with results that preserve model-predicted phrase boundaries.
| CSS balance | semantic-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.
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
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
- 01Predict and aggregate
A phrase model predicts candidate boundaries and assigns a cost to each one.
- 02Calculate layouts
Core measures multiple candidates with the actual font and available width.
- 03Verify in the browser
A fitting native layout is replaced only when the model provides stronger evidence.
- 04Select 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
| Package | Purpose |
|---|---|
@semantic-wrap/core | Boundary prediction, candidate aggregation, layout calculation, and selection |
@semantic-wrap/react | DOM measurement and <br> rendering for React |
@semantic-wrap/en | Experimental phrase model for English titles |
@semantic-wrap/ko | Experimental phrase model for Korean titles |
Core 01
selectLineBreaks
selectLineBreaks runs the complete prediction-to-selection pipeline without depending on React or the DOM.
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
| Field | Type | Description |
|---|---|---|
text | string | Source text to wrap |
model | PhraseModel | Model that predicts boundaries and priorities |
maxWidth | number | Maximum width available to one line |
measureText | (text: string) => number | Measures a string with the target font |
Options
| Field | Type | Default | Description |
|---|---|---|---|
nativeLayout | BaselineLayout | none | Existing line breaks as ascending UTF-16 offsets |
strategy | LineBreakStrategy | default strategy | Overrides aggregation, calculation, or selection |
diagnostics | boolean | false | Includes 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
| Field | Type | Description |
|---|---|---|
text | string | Original input text |
lines | string[] | Text split at selected boundaries |
breaks | number[] | Ascending UTF-16 offsets for line ends |
widths | number[] | Measured width of each line |
selectedCandidates | BreakCandidate[] | Model candidates used by the selected layout |
applied | boolean | Whether calculated breaks should render |
reason | string | Reason returned by selection |
overflow | boolean | Whether a selected line exceeds maxWidth |
diagnostics | LineBreakDiagnostics | Present 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.
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.
| Field | Required | Default | Description |
|---|---|---|---|
levels | yes | — | One or more predictors and their relative penalties |
fallbackPenalty | yes | — | Cost of an allowed boundary not predicted by a level |
boundaryMode | no | spaces | Whitespace or Unicode grapheme boundaries |
Lower penalties are preferred. When multiple levels predict the same boundary, the default aggregation stage keeps the lowest penalty.
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.
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.
| Stage | Default | Customization examples |
|---|---|---|
aggregate | lowestPenalty() | Require model agreement with consensus() |
calculate | optimalLayouts() | Use greedy() or custom line-count rules |
select | balance() | Apply product-specific scores and replacement rules |
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.
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.
const result = selectLineBreaks(input, { diagnostics: true });
console.log(result.diagnostics.predictions);
console.log(result.diagnostics.candidates);| Field | Description |
|---|---|
predictions | Raw boundaries predicted by each model level |
candidates | One candidate list produced by aggregate |
calculatedLayouts | Measured candidates with line count, balance, model cost, and overflow |
nativeLayout | Measured browser layout when supplied |
selection | Source, 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.
| Prop | Required | Default | Description |
|---|---|---|---|
children | yes | — | One plain-text React element |
model | yes | — | Phrase model used to create candidates |
strategy | no | default strategy | Aggregation, calculation, and selection rules |
initial | no | resolved | Exact-first display; native shows source before automatic calculation |
resize | no | immediate | Synchronous updates; settled applies completed work after about 100 ms of stable width |
mode | no | — | Deprecated precise/progressive compatibility; cannot mix with new options |
ref | no | — | HTMLElement ref shared with the child |
<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.
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.
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.
| Field | Required | Default | Description |
|---|---|---|---|
text | yes | — | Source text to measure and split |
model | yes | — | Phrase model used to create candidates |
strategy | no | default strategy | Aggregation, calculation, and selection rules |
diagnostics | no | false | Whether to return intermediate results |
initial | no | resolved | Synchronous initial calculation, or automatic native-first work |
resize | no | immediate | Synchronous updates, or cooperative settled updates |
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
| Field | Type | Description |
|---|---|---|
ref | (HTMLElement | null) => void | Callback ref for the measured element |
selection | LineBreakSelection | null | Null before measurement or during pending text/geometry work; retains the previous result during reference-only revalidation |
diagnostics | LineBreakDiagnostics | null | Diagnostics 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.
import { koTitleModel } from "@semantic-wrap/ko";
import { enTitleModel } from "@semantic-wrap/en";Project 01
Development
bun install
bun run checkbun 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.
