Body text
Body type for one run of ordinary text: three size steps with their own leading and tracking, three tones mixed from the page colour, an em measure, and a p, span or div of your choosing.
cmp_body_text_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_body_text_01 version 1.0.0 with variant "default", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Inherited colour
- Version
- 1.0.0
- Digest
Full digest
sha256-abe136f669064272707195b3d9f289e490e7828246d7e64155b23a1d86caec3a
<script lang="ts" module>
export type BodyTextElement = 'p' | 'span' | 'div';
export type BodyTextSize = 'sm' | 'base' | 'lg';
export type BodyTextTone = 'default' | 'muted' | 'subtle';
export type BodyTextMeasure = 'none' | 'prose' | 'narrow';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Element to render. A paragraph by default. */
as?: BodyTextElement;
/** Size step with its own leading and tracking. A span without one inherits. */
size?: BodyTextSize;
/** Colour role, relative to the surrounding text colour. */
tone?: BodyTextTone;
/** Maximum line length. Ignored on a span. */
measure?: BodyTextMeasure;
/** Id on the element, for aria-describedby and anchors. */
id?: string;
/** Extra layout classes such as margins. */
class?: string;
/** The text, with any inline markup such as links or emphasis. */
children: Snippet;
}
let {
as = 'p',
size,
tone = 'default',
measure = 'prose',
id,
class: className,
children
}: Props = $props();
/*
* Each step pairs its size with its own leading and tracking: open leading for reading, and
* tracking that tightens a little as the type grows. Complete class names, never interpolated.
*/
const SIZES: Record<BodyTextSize, string> = {
sm: 'text-sm leading-[1.5] tracking-normal',
base: 'text-base leading-[1.625] tracking-[-0.011em]',
lg: 'text-lg leading-[1.6] tracking-[-0.014em]'
};
/*
* A span sits inside someone else's line, so it changes the size but never the line height:
* a taller inline box would push that line's leading open. Arbitrary sizes, because the stock
* text-sm, text-base and text-lg also set a line height.
*/
const INLINE_SIZES: Record<BodyTextSize, string> = {
sm: 'text-[0.875rem] tracking-normal',
base: 'text-[1rem] tracking-[-0.011em]',
lg: 'text-[1.125rem] tracking-[-0.014em]'
};
const TONES: Record<BodyTextTone, string> = {
default: 'text-(--_ink)',
muted: 'text-(--_muted)',
subtle: 'text-(--_subtle)'
};
/*
* Measures in em, not ch: Inter's "0" is wider than its average letter, so 65ch holds about
* 84 characters, and Inter averages about 0.46em a character, so 30em holds about 65 and 22em
* about 48, and both scale with the size step,
* so sm, base and lg keep the same number of characters to the line.
*/
const MEASURES: Record<BodyTextMeasure, string> = {
none: '',
prose: 'max-w-[30em]',
narrow: 'max-w-[22em]'
};
/** Own keys only, so an untyped value such as "constructor" falls back like any other. */
const pick = <K extends string>(map: Record<K, string>, key: unknown, fallback: K) =>
map[typeof key === 'string' && Object.hasOwn(map, key) ? (key as K) : fallback];
const tag = $derived<BodyTextElement>(as === 'span' || as === 'div' ? as : 'p');
const inline = $derived(tag === 'span');
const step = $derived<BodyTextSize | undefined>(
typeof size === 'string' && Object.hasOwn(SIZES, size) ? size : inline ? undefined : 'base'
);
</script>
<svelte:element
this={tag}
{id}
class={[
'body-text [overflow-wrap:anywhere]',
pick(TONES, tone, 'default'),
inline
? ['body-text--inline', step && INLINE_SIZES[step]]
: [
'body-text--block font-normal text-pretty [hanging-punctuation:first]',
step && SIZES[step],
pick(MEASURES, measure, 'prose')
],
className
]}
>
{@render children()}
</svelte:element>
<style>
/*
* Public tokens: set --body-text-* on the element or any ancestor. By default the text takes
* the surrounding colour and the muted and subtle tones are mixes of it, so the same tones
* read on a white page, a dark band and a tinted card. On zinc-950 text over white, muted
* is about 7.7:1 and subtle about 5.3:1.
*/
.body-text {
--_ink: var(--body-text-ink, currentColor);
--_muted: var(--body-text-muted, color-mix(in srgb, currentColor 70%, transparent));
--_subtle: var(--body-text-subtle, color-mix(in srgb, currentColor 60%, transparent));
--_accent: var(--body-text-accent, currentColor);
}
/*
* Links are your markup inside the snippet, so their look sits in the components layer under
* :where(): a utility on your own link wins. They keep the text colour and carry a one-pixel
* underline in that same colour at rest, so the mark has the text's own contrast and never
* relies on colour alone. Hover thickens it and draws it in the accent at once,
* because the change is a state, not motion.
*/
@layer components {
.body-text :global(:where(a)) {
color: inherit;
text-decoration-line: underline;
text-decoration-thickness: 1px;
text-decoration-color: currentColor;
text-underline-offset: 0.22em;
text-decoration-skip-ink: auto;
border-radius: 2px;
}
@media (hover: hover) {
.body-text :global(:where(a:hover)) {
text-decoration-color: var(--_accent);
text-decoration-thickness: 2px;
}
}
.body-text :global(:where(a:focus-visible)) {
outline: 2px solid var(--_accent);
outline-offset: 2px;
text-decoration-color: var(--_accent);
}
}
/* Arabic, Hebrew and other right-to-left scripts are never letter-spaced. */
.body-text:dir(rtl) {
letter-spacing: 0;
}
/* Arabic letterforms run taller and deeper than Latin; a paragraph opens its leading. */
.body-text--block:lang(ar),
.body-text--block:lang(fa),
.body-text--block:lang(ur) {
line-height: 1.8;
}
/*
* Chinese, Japanese and Korean: no Latin tracking, a little more leading, and lines that
* break at sensible points. Korean keeps its words whole; Japanese breaks at phrases where
* the browser can find them.
*/
.body-text:lang(zh),
.body-text:lang(ja),
.body-text:lang(ko) {
letter-spacing: 0;
line-break: strict;
}
.body-text--block:lang(zh),
.body-text--block:lang(ja),
.body-text--block:lang(ko) {
line-height: 1.75;
}
.body-text:lang(ko) {
word-break: keep-all;
}
@supports (word-break: auto-phrase) {
.body-text:lang(ja) {
word-break: auto-phrase;
}
}
</style>
Usage#
On this pageWrap a run of text in it and choose the element, size, tone and measure; links and emphasis go inside as ordinary markup. It does not render Markdown or HTML strings, truncate or clamp text, set a font family or space paragraphs apart: margins between blocks are yours, through class or a parent gap.
- Suggested location
src/lib/components/body-text-01- Required props
children
Limitations
- Tones are mixes of the surrounding text colour: muted is 70% and subtle 60% of it. On a page whose text is
zinc-900or darker they clear 4.5:1 on white; on a lighter body colour, check them or set--body-text-mutedand--body-text-subtle. - The mixes use transparency, so over a photograph or a pattern the background shows through muted and subtle text. Set opaque values through the tokens there.
- No font family is set; the text inherits the surrounding font family. Leading, tracking and the em measure are tuned for a neo-grotesque such as Inter.
- A span cannot hold block content. Pass as="div" when the children include lists or other blocks; as="p" cannot hold them either.
- text-wrap: pretty, hanging-punctuation and word-break: auto-phrase are progressive: browsers without them wrap and set punctuation normally.
- Right-to-left, Arabic and CJK corrections (no letter-spacing, more leading on paragraphs) are scoped CSS, so they beat a tracking or leading utility passed through class in those languages.
Example
<script lang="ts">
import BodyText from '$lib/components/body-text-01/BodyText.svelte';
</script>
<BodyText>
Guests can open a single board and comment on any task.
<a href="/help/guests">Change what a guest can do</a> at any time.
</BodyText>
<BodyText as="div" size="sm" tone="muted" id="workspace-url-help">
Lowercase letters, numbers and hyphens.
</BodyText>Body text#
One run of body copy, set at a readable size and measure, in the element you choose. It is the paragraph under a card title, the help text under a field and the muted note in a heading line.
<script lang="ts">
import BodyText from '$lib/components/body-text-01/BodyText.svelte';
</script>
<!-- A paragraph at the default size, tone and measure. -->
<BodyText>
Guests can open a single board and comment on any task.
<a href="/help/guests">Change what a guest can do</a> at any time.
</BodyText>
<!-- Help text under a field: a div with an id for aria-describedby. -->
<label for="workspace-url">Workspace address</label>
<input id="workspace-url" aria-describedby="workspace-url-help" />
<BodyText as="div" size="sm" tone="muted" measure="none" id="workspace-url-help">
Lowercase letters, numbers and hyphens.
</BodyText>
<!-- A muted note inside a heading line: a span changes the tone and nothing else. -->
<h3 class="text-2xl font-semibold">
Team plan <BodyText as="span" tone="muted">$12 per seat per month</BodyText>
</h3>The scale#
| Size | Font size | Leading | Tracking |
|---|---|---|---|
sm |
14 px | 1.5 (21 px) | 0 |
base |
16 px | 1.625 (26 px) | −0.011 em |
lg |
18 px | 1.6 (29 px) | −0.014 em |
The measure is in em, so prose (30em) holds about 65 characters and narrow (22em) about 48
at every size. max-w-prose is 65ch, which holds about 84 characters of Inter, because ch is
the width of the zero.
Spans#
A span sits inside a line that something else has set. Without a size it keeps that line's
size, leading and weight and changes only the colour. With a size it changes the font size
and tracking but never the line height (the sizes are written as arbitrary values because
text-sm and its siblings also set a line height), so the line it sits in keeps its leading. A span never
takes a measure.
Spacing paragraphs#
The component sets no margins. Put consecutive blocks in a parent with gap-4 (1rem) or pass class="mt-4".
Colour#
Every tone follows the surrounding text colour: default is that colour, muted and subtle are 70% and 60% mixes of it. On a dark band or a tinted page, set the section's text colour and leave the tokens alone:
<section class="bg-[#fbf7f0] text-[#4a2c17]">
<BodyText size="lg">Sourdough out of the oven at 7, usually gone by noon.</BodyText>
<BodyText size="sm" tone="muted">Order by Thursday evening.</BodyText>
</section>To pin the tones to your palette instead, set --body-text-ink, --body-text-muted and
--body-text-subtle on the section. --body-text-accent colours a link's underline on hover
and its focus ring.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
as | 'p' | 'span' | 'div' | No | 'p' | Element to render. A span sits inside someone else's line; p and div are blocks. Any other value renders a p. |
size | 'sm' | 'base' | 'lg' | No | None | Size step: sm 14/21 px, base 16/26 px, lg 18/29 px, each with its own tracking. Blocks default to base. A span without a size inherits the size and leading of its line; with one it changes the size but not the line height. |
tone | 'default' | 'muted' | 'subtle' | No | 'default' | Colour role. Default is the surrounding text colour (or --body-text-ink); muted and subtle are 70% and 60% mixes of the surrounding colour, both above 4.5:1 for zinc-950 on white. |
measure | 'none' | 'prose' | 'narrow' | No | 'prose' | Maximum line length in em: prose about 65 characters, narrow about 48, none leaves it to the parent. Ignored on a span. |
id | string | No | None | Id on the element, for aria-describedby on a field or an anchor link. |
class | string | No | None | Extra classes on the element, for margins and layout. Not a reliable way to change size or colour; use the props and tokens. |
children | Snippet | Yes | None | The text, with inline markup such as links, strong and em. |
Customization#
On this pagePick element, size, tone and measure through props. The tones follow the surrounding text colour, so on a dark or tinted page set that colour and leave the tokens alone; --body-text-ink, --body-text-muted, --body-text-subtle and --body-text-accent override each one. Edit the SIZES and MEASURES maps in the source to change the scale.
- Colour: by default the text is the surrounding text colour, and muted and subtle are 70% and 60% mixes of it. On a dark band, set the band's text colour (for example
text-zinc-50onbg-zinc-950) and leave the tokens alone. - Tinted page: set the section's text colour (a warm brown on cream, say) and all three tones follow it, because muted and subtle are mixes of the surrounding colour. Check both still clear 4.5:1.
- Ink only:
--body-text-inksets the default tone alone, for text that should differ from the page around it. Muted and subtle keep mixing the surrounding colour; set--body-text-mutedand--body-text-subtleto move them too. - Fixed tones: to pin the three tones to your palette, set
--body-text-ink:#18181b,--body-text-muted:#52525band--body-text-subtle:#71717a. - Links: they keep the text colour and carry a 1 px underline in that colour at rest, so the mark has the text's own contrast. Hover thickens it to 2 px in
--body-text-accent, which also colours the focus ring; set it to your brand colour to tie links to your buttons, and keep it at 3:1 against the background. - Scale: the SIZES map holds each step's font size, leading and tracking as complete Tailwind classes. INLINE_SIZES is the same scale for spans, written as arbitrary sizes because the stock
text-sm, text-base andtext-lgalso set a line height. Edit both together. - Measure: MEASURES holds prose (30em) and narrow (22em). They are in em so every size keeps the same number of characters to the line; count rendered characters before changing them.
- Spacing between paragraphs is not built in. Stack blocks in a parent with
gap-4(1rem) or pass class="mt-4".
Public CSS variables
| Variable | Token |
|---|---|
--body-text-ink | ink |
--body-text-muted | muted |
--body-text-subtle | subtle |
--body-text-accent | accent |
Accessibility#
On this page- Renders a native p, span or div with no role or ARIA; its semantics are the element you choose. Pick p for a paragraph, div for a description that may hold blocks, span inside another element's line.
- Leading is 1.5 or more on every block step (1.625 at base), and nothing has a fixed height, so user text-spacing overrides (WCAG 1.4.12) reflow instead of clipping.
- Muted and subtle are 70% and 60% of the text colour: about 7.7:1 and 5.3:1 for
zinc-950on white, and above 9:1 and 6:1 forzinc-50onzinc-950. Re-check them if you set the tokens or a lighter body colour. - Links inside are underlined at rest in the text's own colour, so they do not rely on colour alone (WCAG 1.4.1) and the underline has the text's contrast. Focus shows a 2 px ring in the accent with a 2 px offset; if you set
--body-text-accent, keep it at 3:1 against the background. - Use the id prop with
aria-describedbywhen the text describes a form field. - Long words and URLs wrap with overflow-wrap: anywhere, so the text never forces horizontal scrolling at 320 px.
- Letter-spacing is removed under dir="rtl" and for Chinese, Japanese and Korean; paragraphs in Arabic and CJK get more leading.
Known limitations
- Contrast is not checked by the component; it follows the colour of the surrounding text.
- The component cannot stop block content inside a span or p; invalid nesting is repaired by the browser's parser and can break hydration.
Release details#
On this page- Integration
- Presentational
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 1 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.