Typography
Headings and text that size themselves fluidly to the screen (or their container), plus styling for long-form content.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add typographypnpm dlx ermineui add typographyyarn dlx ermineui add typographybunx ermineui add typographyThat copies 4 files into src/components/ui/typography/. Files you already have are kept.
Use
import { Prose, Typography } from '@/components/ui/typography'<Typography variant="display">Ship it</Typography>
<Typography variant="h2">Every push gets a preview</Typography>
<Typography variant="lead">A short introduction.</Typography>
<Typography>Body text.</Typography>
<Typography as="h2" variant="h4">Looks like an h4, is an h2 in the page's outline</Typography>
<Prose><!-- rendered Markdown or CMS content: h1–h6, p, lists, blockquote, code --></Prose>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
Prose.vue | src/components/ui/typography/Prose.vue |
Typography.vue | src/components/ui/typography/Typography.vue |
fluid.css | src/components/ui/typography/fluid.css |
index.ts | src/components/ui/typography/index.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
Prose.vue 164 lines
<script setup lang="ts">
import './fluid.css'
withDefaults(
defineProps<{
/** What the text sizes itself to: the screen, or this block's own width (for content in a column or panel). */
fluid?: 'viewport' | 'container'
}>(),
{ fluid: 'viewport' },
)
</script>
<template>
<!-- For content you do not write tag by tag: rendered Markdown, an article from a CMS. Plain HTML goes in, styled text comes out. -->
<div class="er-prose-root" :class="{ 'er-fluid-container': fluid === 'container' }">
<div class="er-prose"><slot /></div>
</div>
</template>
<style>
@layer components {
.er-prose {
/* Lines stay a comfortable length to read however wide the page is. */
max-inline-size: 65ch;
color: var(--er-foreground);
font-family: var(--er-font-prose);
line-height: 1.65;
overflow-wrap: break-word;
}
/* Space between blocks is in em, so it grows and shrinks with the text. */
.er-prose > * {
margin-block: 0;
}
.er-prose > * + * {
margin-block-start: 1.1em;
}
.er-prose :is(h1, h2, h3, h4, h5, h6) {
font-family: var(--er-font-display);
font-weight: var(--er-weight-heading);
text-wrap: balance;
}
.er-prose h1 {
--er-step: 5;
font-weight: var(--er-weight-display);
line-height: 1.1;
letter-spacing: calc(-0.03em * var(--er-tracking-heading));
text-transform: var(--er-display-case);
}
.er-prose h2 {
--er-step: 3;
line-height: 1.2;
letter-spacing: calc(-0.02em * var(--er-tracking-heading));
}
.er-prose h3 {
--er-step: 2;
line-height: 1.3;
letter-spacing: calc(-0.01em * var(--er-tracking-heading));
}
.er-prose :is(h4, h5, h6) {
--er-step: 1;
line-height: 1.35;
}
/* More room above a heading than below it, so it belongs to what follows. */
.er-prose > * + :is(h2, h3, h4, h5, h6) {
margin-block-start: 1.6em;
}
.er-prose > :is(h1, h2, h3, h4, h5, h6) + * {
margin-block-start: 0.6em;
}
.er-prose :is(small, figcaption) {
--er-step: -1;
color: var(--er-muted-foreground);
}
.er-prose a {
color: inherit;
font-weight: var(--er-weight-medium);
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
}
.er-prose a:hover {
text-decoration-thickness: 2px;
}
.er-prose a:focus-visible {
border-radius: var(--er-radius-sm);
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
.er-prose strong {
font-weight: 650;
}
.er-prose :is(ul, ol) {
padding-inline-start: 1.4em;
}
.er-prose li + li {
margin-block-start: 0.35em;
}
.er-prose li::marker {
color: var(--er-muted-foreground);
}
.er-prose blockquote {
margin-inline: 0;
padding-inline-start: 1em;
border-inline-start: 2px solid var(--er-border);
color: var(--er-muted-foreground);
}
.er-prose hr {
height: 1px;
margin-block: 2em;
border: 0;
background: var(--er-border);
}
.er-prose :is(code, pre) {
--er-step: -1;
font-family: var(--er-font-mono);
}
.er-prose :not(pre) > code {
padding: 0.15em 0.35em;
border-radius: var(--er-radius-sm);
background: var(--er-muted);
}
.er-prose pre {
padding: 1em;
border: 1px solid var(--er-border);
border-radius: var(--er-radius-md);
background: var(--er-muted);
line-height: 1.6;
overflow-x: auto;
}
/* Code inside a block is already sized by the block. */
.er-prose pre code {
font-size: inherit;
}
.er-prose img {
max-width: 100%;
height: auto;
border-radius: var(--er-radius-md);
}
}
</style>Typography.vue 156 lines
<script setup lang="ts">
import { computed, type Component } from 'vue'
import './fluid.css'
/** What each variant looks like: which step of the size scale it sits on, and the tag it uses unless told otherwise. */
const VARIANTS = {
display: { step: 6, tag: 'h1' },
h1: { step: 5, tag: 'h1' },
h2: { step: 4, tag: 'h2' },
h3: { step: 3, tag: 'h3' },
h4: { step: 2, tag: 'h4' },
lead: { step: 1, tag: 'p' },
body: { step: 0, tag: 'p' },
small: { step: -1, tag: 'p' },
caption: { step: -1.5, tag: 'p' },
overline: { step: -1.5, tag: 'p' },
} as const
const props = withDefaults(
defineProps<{
/** How the text looks. Separate from `as`, so a heading can look smaller than its level suggests. */
variant?: keyof typeof VARIANTS
/** The tag or component to render. Pick it for meaning (the page's outline), not for size. */
as?: string | Component
/** Override the variant's size: a step on the scale. 0 is body text; halves are allowed. */
step?: number
tone?: 'default' | 'muted'
/** Keep lines to a comfortable reading length (about 65 characters). */
measure?: boolean
/** Even out line lengths. On by default for headings; set false to turn off, true to force on. */
balance?: boolean
/** Cut the text off with "…" after this many lines. */
lines?: number
}>(),
{ variant: 'body', tone: 'default', balance: undefined },
)
const settings = computed(() => VARIANTS[props.variant])
const balanced = computed(() => props.balance ?? settings.value.step >= 2)
</script>
<template>
<component
:is="as ?? settings.tag"
class="er-fluid-type er-typography"
:data-variant="variant"
:data-tone="tone"
:data-measure="measure ? '' : undefined"
:data-balance="balanced ? '' : undefined"
:data-clamp="lines ? '' : undefined"
:style="{ '--er-step': step ?? settings.step, '--er-lines': lines }"
>
<slot />
</component>
</template>
<style>
@layer components {
.er-typography {
margin: 0;
color: var(--er-foreground);
font-family: var(--er-font-prose);
font-weight: 400;
/* Line height and letter spacing have no units or use em, so they scale with the fluid size. */
line-height: 1.6;
overflow-wrap: break-word;
}
.er-typography[data-tone='muted'] {
color: var(--er-muted-foreground);
}
.er-typography:is([data-variant='display'], [data-variant='h1'], [data-variant='h2'], [data-variant='h3'], [data-variant='h4']) {
font-family: var(--er-font-display);
}
.er-typography:is([data-variant='display'], [data-variant='h1']) {
text-transform: var(--er-display-case);
}
/* Labels are interface text, set in the interface font. */
.er-typography:is([data-variant='caption'], [data-variant='overline']) {
font-family: var(--er-font-sans);
}
/* The bigger the text, the tighter it is set. */
.er-typography[data-variant='display'] {
font-weight: var(--er-weight-display);
line-height: 1.04;
letter-spacing: calc(-0.035em * var(--er-tracking-heading));
}
.er-typography[data-variant='h1'] {
font-weight: var(--er-weight-display);
line-height: 1.1;
letter-spacing: calc(-0.03em * var(--er-tracking-heading));
}
.er-typography[data-variant='h2'] {
font-weight: var(--er-weight-heading);
line-height: 1.15;
letter-spacing: calc(-0.025em * var(--er-tracking-heading));
}
.er-typography[data-variant='h3'] {
font-weight: var(--er-weight-semibold);
line-height: 1.25;
letter-spacing: calc(-0.02em * var(--er-tracking-heading));
}
.er-typography[data-variant='h4'] {
font-weight: var(--er-weight-semibold);
line-height: 1.3;
letter-spacing: calc(-0.01em * var(--er-tracking-heading));
}
.er-typography[data-variant='lead'] {
color: var(--er-muted-foreground);
line-height: 1.5;
}
.er-typography[data-variant='small'],
.er-typography[data-variant='caption'] {
line-height: 1.5;
}
.er-typography[data-variant='caption'] {
color: var(--er-muted-foreground);
}
.er-typography[data-variant='overline'] {
color: var(--er-muted-foreground);
font-weight: var(--er-weight-semibold);
line-height: 1.4;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.er-typography[data-measure] {
max-inline-size: 65ch;
}
.er-typography[data-balance] {
text-wrap: balance;
}
.er-typography[data-clamp] {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--er-lines);
line-clamp: var(--er-lines);
}
}
</style>fluid.css 38 lines
/*
* Fluid type. One rule sizes every piece of fluid text; an element only says which step of the
* scale it is, with --er-step (0 is body text, 1 is one size up, -1 one size down, halves allowed).
*
* The size is a clamp() of three values:
* the smallest it gets — the narrow-screen size
* a value that grows with the available width
* the largest it gets — the wide-screen size
* The growing value is written as "rem + a share of the width", never width alone, so the text still
* gets bigger when the reader raises their browser's text size.
*
* The scale itself comes from the --er-type-* variables in ermine.css. The fallbacks here are the same
* values, so the component also works with a stylesheet that predates them.
*/
@layer components {
.er-fluid-type,
.er-prose,
.er-prose :is(h1, h2, h3, h4, h5, h6, small, figcaption, pre, code) {
--er-type-step-min: calc(var(--er-type-min, 1) * pow(var(--er-type-ratio-min, 1.2), var(--er-step, 0)));
--er-type-step-max: calc(var(--er-type-max, 1.125) * pow(var(--er-type-ratio-max, 1.3), var(--er-step, 0)));
font-size: clamp(
calc(var(--er-type-step-min) * 1rem),
calc(
var(--er-type-step-min) * 1rem + (var(--er-type-step-max) - var(--er-type-step-min)) *
(var(--er-fluid-width, 100vw) - var(--er-type-from, 20rem)) / var(--er-type-range, 60)
),
calc(var(--er-type-step-max) * 1rem)
);
}
/* Put this class on a box (a card, a sidebar, a panel) and the fluid text inside it sizes itself
to that box instead of to the screen: the same heading is smaller in a narrow column. */
.er-fluid-container {
container-type: inline-size;
--er-fluid-width: 100cqi;
}
}index.ts 4 lines
/** Typography — headings and text that size themselves fluidly to the screen (or their container), plus styling for long-form content. */
export { default as Prose } from './Prose.vue'
export { default as Typography } from './Typography.vue'