@vinyasa/typography
v2.0.3
Published
13 typography primitives: Text, Heading, Paragraph, Label, Caption, Link, Code, Kbd, Blockquote, Mark, Highlight, List, MarkdownRenderer.
Readme
@vinyasa/typography
13 typography primitives: Text, Heading, Paragraph, Label, Caption, Link, Code, Kbd, Blockquote, Mark, Highlight, List, MarkdownRenderer.
Installation
pnpm add @vinyasa/typography @vinyasa/layout @vinyasa/tokens react react-dom@vinyasa/tokens and @vinyasa/layout are peer dependencies — this package's spacing props reuse the same token-derived scale @vinyasa/layout established, and MarkdownRenderer reuses @vinyasa/layout's Box for its wrapper. Rendering requires a VinyasaProvider (from @vinyasa/tokens) above these components in the tree.
This package has no root export — every component is subpath-only (import Text from '@vinyasa/typography/text', never from '@vinyasa/typography'). A root barrel re-exporting all 13 components would let a bundler tree-shake the unused JS down to just the ones you import, but the CSS side-effect imports the others carry are not eligible for the same tree-shaking (confirmed empirically with both esbuild and Rollup on this monorepo's other packages). Removing the root entry entirely makes that the only possible outcome, not something that depends on your bundler being clever enough to shake it out.
New tokens this package introduces
@vinyasa/tokens gained one new color role and three larger font sizes to support headings:
| Token | Value | Used by |
| ------------- | ----------------------- | --------------------------------- |
| fontSize2xl | 1.5rem (24px) | Heading (h2 default) |
| fontSize3xl | 1.875rem (30px) | Heading (h1 default) |
| fontSize4xl | 2.25rem (36px) | Heading (size="4xl" override) |
| textMuted | #6b7280 (gray[500]) | Text color="muted", Caption |
These extend the existing fontSize scale (xs–xl were already Tailwind's own type steps up to 20px) purely upward — no new scale, no new primitives beyond textMuted's reuse of an already-existing gray.
Spacing
Every component accepts the same margin/padding props as @vinyasa/layout — see its README for the full prop and token tables.
Usage
Text
The one real recipe every other component in this package builds on.
import Text from '@vinyasa/typography/text';
<Text size="lg" weight="medium" color="muted">
Body copy
</Text>;size: xs/sm/md/lg/xl/2xl/3xl/4xl. weight: regular/medium/semibold/bold. align: left/center/right. color: default/muted/primary/info/success/warning/error.
Other props: italic (boolean), textTransform (uppercase/lowercase/capitalize/none), decoration (underline/line-through/none), letterSpacing/lineHeight (tight/normal/wide and tight/normal/loose respectively, off the shared spacing tokens), textWrap (wrap/balance/pretty/nowrap), lineClamp (number of lines before an ellipsis), inherit (boolean — skip Text's own font-size/weight/color/line-height and pick up the closest styled ancestor's instead), and variant="gradient" + gradient={{ from, to, deg? }} for gradient text.
truncate: boolean | 'start' | 'end' — true is an alias for 'end' (clips the end with an ellipsis); 'start' clips the beginning instead, for cases like a long file path where the filename at the end matters most.
<Text truncate="start" style={{ width: '12rem' }}>
/Users/rohit/projects/vinyasa/report-final.pdf
</Text>Showing the full text on hover when truncated
truncate/lineClamp only clip visually — they don't add any hover affordance on their own. Pair Text with @vinyasa/typography's useIsTruncated hook and @vinyasa/overlay's Tooltip to show the full content only when it's actually overflowing, not on every hover:
import Text from '@vinyasa/typography/text';
import { useIsTruncated } from '@vinyasa/typography/use-is-truncated';
import { Tooltip } from '@vinyasa/overlay/tooltip';
import { useState } from 'react';
const TruncatedLabel = ({ text }: { text: string }) => {
const [ref, isTruncated] = useIsTruncated<HTMLSpanElement>([text]);
const [open, setOpen] = useState(false);
return (
<Tooltip label={text} open={isTruncated && open} onOpenChange={setOpen}>
<Text ref={ref} truncate="end" style={{ maxWidth: '12rem' }}>
{text}
</Text>
</Tooltip>
);
};useIsTruncated returns a ref (attach it to the truncated element) and a boolean, re-checked on every resize of that element via ResizeObserver. Pass the truncated content itself as the hook's deps argument (as above) so a content change with no resize still re-checks. @vinyasa/typography itself stays free of any dependency on @vinyasa/overlay — the hook only measures overflow; wiring it to a Tooltip is left to the consumer, same as this example.
Tooltip stays mounted unconditionally, with open mirrored into local state via onOpenChange and forced closed whenever isTruncated is false — conditionally rendering the Tooltip wrapper only once truncated would remount Text's DOM node (it moves one level deeper, under Tooltip.Trigger's Slot), detaching useIsTruncated's ResizeObserver from the live element in the process.
Heading
import Heading from '@vinyasa/typography/heading';
<Heading as="h1">Page title</Heading>
<Heading as="h3" size="lg">Visually smaller h3</Heading>as (h1–h6, default h2) picks a sensible default size per level (h1→3xl … h6→sm) — pass size explicitly to decouple the visual size from the semantic level, same as Radix Themes' Heading.
Paragraph, Label, Caption
Thin, fixed-prop wrappers over Text — not duplicated CSS.
import Paragraph from '@vinyasa/typography/paragraph';
import Label from '@vinyasa/typography/label';
import Caption from '@vinyasa/typography/caption';
<Paragraph>Some long-form body text.</Paragraph>
<Label htmlFor="email">Email address</Label> {/* Text as="label" size="sm" weight="medium" */}
<Caption>Last updated 2 hours ago</Caption> {/* Text as="span" size="xs" color="muted" */}Link
import Link from '@vinyasa/typography/link';
<Link href="/docs">Read the docs</Link>
<Link href="https://example.com" target="_blank" rel="noreferrer">External link</Link>Real anchor props (href, target, rel), plus size/weight/truncate from Text. color is narrowed to primary/error.
Code
import Text from '@vinyasa/typography/text';
import Code from '@vinyasa/typography/code';
<Text>Run <Code>pnpm install</Code> first.</Text>
<Code block>{`function greet() {\n return "hi";\n}`}</Code>block renders <pre><code> instead of inline <code>.
Kbd
import Kbd from '@vinyasa/typography/kbd';
<Kbd>⌘</Kbd> + <Kbd>K</Kbd>;Blockquote
import Blockquote from '@vinyasa/typography/blockquote';
<Blockquote>Design is not just what it looks like. Design is how it works.</Blockquote>;Mark
Native <mark>, styled off the existing statusWarning tokens (no new tokens needed).
import Text from '@vinyasa/typography/text';
import Mark from '@vinyasa/typography/mark';
<Text>
The quick brown fox <Mark>jumps over</Mark> the lazy dog.
</Text>;Highlight
Wraps every case-insensitive occurrence of highlight in Mark. highlight takes one term or a list. children must be a plain string — rich content can't be reliably split and re-wrapped.
import Highlight from '@vinyasa/typography/highlight';
<Highlight highlight={['React', 'vanilla-extract']}>
This design system is built with React and vanilla-extract
</Highlight>;List
List/List.Item — no simple non-compound form, since composing .Item children is the natural simple form for a list.
import List from '@vinyasa/typography/list';
<List type="ordered" spacing={2}>
<List.Item>Write the contract</List.Item>
<List.Item>Stabilize tokens and layout</List.Item>
</List>
<List icon={<CheckIcon />} withPadding={false}>
<List.Item>Consistent spacing scale</List.Item>
<List.Item icon={<WarningIcon />}>One item with its own override icon</List.Item>
</List>type: unordered (default, <ul>) / ordered (<ol>). icon on List sets every item's default icon; an item's own icon overrides it. withPadding (default true) keeps the browser's list indent — turn it off once every item has an icon, which already replaces the native marker.
MarkdownRenderer
Wraps react-markdown, mapping every markdown element to this package's own components (headings → Heading, paragraphs → Paragraph, links → Link, code → Code, blockquotes → Blockquote) so rendered markdown automatically picks up your theme.
import MarkdownRenderer from '@vinyasa/typography/markdown-renderer';
<MarkdownRenderer>{'# Title\n\nSome *text* with `inline code`.'}</MarkdownRenderer>;Subpath imports
Every component is imported by its own subpath (e.g. @vinyasa/typography/text) — there is no root @vinyasa/typography entry, so import { X } from '@vinyasa/typography' fails to resolve. See "This package has no root export" above for why.
Development
From the repository root:
pnpm --filter @vinyasa/typography build
pnpm --filter @vinyasa/typography test
pnpm --filter @vinyasa/typography lint
pnpm storybook # Typography/*