Skip to content
Palette Inherited colour
Download ZIP

Inherited colour palette · 5.3 KB ZIP File receipt View as Markdown View code

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.
cmp_body_text_01 · version 1.0.0 · Inherited colour palette
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
BodyText.svelte Svelte · 5.9 KB Raw
<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>

Wrap 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-900 or darker they clear 4.5:1 on white; on a lighter body colour, check them or set --body-text-muted and --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

Svelte
<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.

Svelte
<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:

Svelte
<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
NameTypeRequiredDefaultDescription
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'NoNoneSize 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.
idstringNoNoneId on the element, for aria-describedby on a field or an anchor link.
classstringNoNoneExtra classes on the element, for margins and layout. Not a reliable way to change size or colour; use the props and tokens.
childrenSnippetYesNoneThe text, with inline markup such as links, strong and em.

Customization#

On this page

Pick 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-50 on bg-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-ink sets 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-muted and --body-text-subtle to move them too.
  • Fixed tones: to pin the three tones to your palette, set --body-text-ink: #18181b, --body-text-muted: #52525b and --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 and text-lg also 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

VariableToken
--body-text-inkink
--body-text-mutedmuted
--body-text-subtlesubtle
--body-text-accentaccent

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-950 on white, and above 9:1 and 6:1 for zinc-50 on zinc-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-describedby when 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.