Typeset

Two typefaces, one type scale, and one container class for long-form content. This is how text is set across nsdev/ui.

Typefaces

Two faces cover every piece of text, and there is no third. Host Grotesk sets the interface: headings, body copy, buttons and menus. Geist Mono sets code, so inline code matches the source panels on component pages.

TokenValueWhat it is for
--font-sansvar(--font-host-grotesk), system-ui, -apple-system, "Segoe UI", Roboto, sans-serifHost Grotesk. Interface text, headings and body copy.
--font-monovar(--font-geist-mono), 'SF Mono', Consolas, monospaceGeist Mono. Code, source panels and inline code.

Keeping the system closed means every piece of text maps to a role without a judgement call. The mono face isn't optional: component pages show real source code, and it has to sit comfortably next to the surrounding text.

Loading

Both faces load through next/font/google in src/app/layout.tsx. Next downloads them at build time and serves them from your own domain, so visitors never make a request to Google. Each face exposes a CSS variable, --font-host-grotesk and --font-geist-mono, and the --font-sans and --font-mono tokens above point at them, with system fonts as the fallback while a face loads. The page body uses font-sans by default.

Scale

Sizes are set with Tailwind utilities, and a few steps are fluid so large headings fit small screens.

RoleSizeWeightUsed for
Display32–44px, 56px from smBoldThe home page headline. Scales with the viewport on phones.
Page title36px, 48px from smBoldThe title at the top of a doc page.
Heading24pxSemiboldSections of a page, the entries in “On this page”.
Subheading20pxSemiboldSub-sections inside a section.
Lead14pxRegularThe opening paragraph of a page, in a duller color.
Body17pxRegularParagraphs and lists in long-form content, with 28px line height.
Interface16pxMedium or semiboldNavigation, menus and buttons.
Code14px, 0.875em inlineRegularCode blocks, and inline code relative to the text around it.
Caption11pxMediumSmall badges and tags.

Colors come from the theme. Body text uses text, and text-secondary is kept for labels and larger text. See Theming for the rules.

Long-form content

Rendered content such as docs, articles and notes has no styles of its own, so one class sets them all. Wrap it in doc-prose and headings, paragraphs, lists, links, inline code, code blocks and tables are styled to the scale above. This page is built that way.

<div className="doc-prose">
  <h2 id="installation">Installation</h2>
  <p>Content goes here.</p>
</div>

Write each section heading as <h2 id="…"> and it also appears in the “On this page” column. The class lives in the components layer of src/app/globals.css.

Rhythm

Spacing follows one rule. Each block sits one step below the one before it, and headings add their own space above and pull the block under them closer. Space only ever opens above a block, never below, so adding content to the end of a page doesn't change the blocks already there.

  • Blocks: 1rem between paragraphs, lists, code blocks and tables.
  • Headings: 3.5rem above a heading and 0.75rem between it and the block after it. Subheadings get 2rem above.
  • Lists: 0.5rem between items, with the markers in text-secondary.

Specimen

This is every element in one place, set by doc-prose exactly as above.

A subheading

Body text with a link, some bold text and an inline code sample, set at 17px on a 28px line.

  • List items are spaced by half a rem.
  • Markers use the secondary text color.

Tables and code

Tables stay real tables. Wide ones sit in a container that scrolls sideways, and on phones each row stacks into a block so nothing is squeezed. Code blocks scroll horizontally instead of wrapping, and take keyboard focus so they can be scrolled without a mouse.

Overrides

The styles sit in the components layer, so any Tailwind utility on an element wins without !important. This paragraph sets text-lg and is larger than the body text around it:

One element, one utility, no override needed.

<div className="doc-prose">
  <p className="text-lg">One element, one utility, no override needed.</p>
</div>

Source

This is the live doc-prose CSS, read from src/app/globals.css when the site is built, so it always matches the styles you see on this page.

/* Long-form content (docs). Wrap page content in .doc-prose. Write sections as
   <h2 id="..."> so they appear in the "On this page" column. */
@layer components {
  .doc-prose {
    @apply text-[1.0625rem] leading-7 text-text;
  }
  .doc-prose > * + * {
    @apply mt-4;
  }
  .doc-prose .lead {
    @apply text-sm leading-6 text-text/70;
  }
  .doc-prose h2 {
    @apply mt-14 text-2xl leading-tight font-semibold text-text;
  }
  .doc-prose h2 + * {
    @apply mt-3;
  }
  /* The live demos sit inside .doc-prose. Their wrapper has .demo-reset, so the page text styles
     that would change a component (heading margins, list padding, link underlines) skip it. */
  .doc-prose h3:not(.demo-reset *) {
    @apply mt-8 text-xl leading-tight font-semibold text-text;
  }
  .doc-prose ul:not(.demo-reset *) {
    @apply list-disc space-y-2 pl-5 marker:text-text-secondary;
  }
  .doc-prose strong {
    @apply font-semibold;
  }
  .doc-prose a:not(.demo-reset *) {
    @apply rounded-sm underline decoration-text-secondary underline-offset-4 transition-colors outline-hidden hover:decoration-text focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-text;
  }
  .doc-prose pre {
    @apply overflow-x-auto rounded-lg border border-border bg-surface p-4 font-mono text-sm leading-6;
  }
  .doc-prose pre code {
    @apply rounded-none bg-transparent p-0 text-[1em];
  }
  /* Tables are a normal grid from sm up; on phones each row stacks into a block. */
  .doc-prose table,
  .doc-prose tbody,
  .doc-prose tr {
    @apply max-sm:block;
  }
  .doc-prose table {
    @apply w-full border-collapse text-left text-base;
  }
  .doc-prose thead {
    @apply max-sm:sr-only;
  }
  .doc-prose tr {
    @apply max-sm:border-b max-sm:border-border max-sm:py-3;
  }
  .doc-prose th {
    @apply border-b border-border-strong py-2 pr-6 font-medium text-text-secondary;
  }
  .doc-prose td {
    @apply border-b border-border py-2.5 pr-6 align-top max-sm:block max-sm:border-0 max-sm:py-0.5 max-sm:pr-0;
  }
  .doc-prose td[data-label]::before {
    @apply hidden text-text-secondary max-sm:inline;
    content: attr(data-label) ": ";
  }
  /* Syntax-highlighted code (Shiki). The block supplies its own background and border, so the
     <pre> Shiki generates is flattened and given line numbers. */
  .doc-prose .code-panel pre {
    @apply m-0 overflow-x-auto rounded-none border-0 p-4 pr-14;
    background: transparent !important;
  }
  .code-panel code {
    counter-reset: line;
  }
  .code-panel .line::before {
    counter-increment: line;
    content: counter(line);
    display: inline-block;
    width: 1.75rem;
    margin-right: 1.25rem;
    text-align: right;
    color: var(--color-border-strong);
  }
  .doc-prose code {
    @apply rounded-sm bg-surface px-1.5 py-0.5 font-mono text-[0.875em];
  }
}