@crometrics/lp-design-system
React 18/19 component library for landing pages. Ships unbundled ESM (one module per
component) and a standalone stylesheet; consumers theme it exclusively through --lp-* CSS variables and need no
Tailwind setup of their own.
Install
The package is published publicly to npm:
pnpm add @crometrics/lp-design-system
Releases through 0.5.0 also went to GitHub Packages; later ones are on npm only. The other
@crometrics packages are still published to GitHub Packages. If your .npmrc (project or ~/.npmrc) maps the scope there —
@crometrics:registry=https://npm.pkg.github.com/ — that mapping applies to every
@crometrics package, so the command above resolves against GitHub Packages and never sees
npm releases. Either remove the mapping if you need no other @crometrics packages, or pin
this one package to npm with pnpm's npmjs: registry prefix (pnpm 11.1+):
pnpm add "@crometrics/lp-design-system@npmjs:^0.5.0"
which saves "@crometrics/lp-design-system": "npmjs:^0.5.0" to package.json, so updates keep
coming from npm while other @crometrics packages still resolve from GitHub Packages. npm
and Yarn have no per-package registry override.
react and react-dom ^18.0.0 || ^19.0.0 are peer dependencies and must be installed by
the consumer. Every story test runs on both React 18.3.1 (what HubSpot CMS React pins) and
React 19.
Usage
Import the stylesheet once at the app root, then use components anywhere:
// app entry (e.g. src/main.tsx, or app/layout.tsx in Next.js)
import '@crometrics/lp-design-system/styles.css';
Import each component from its own entry point — the kebab-case component name:
import { Button } from '@crometrics/lp-design-system/button';
import { PromoBanner } from '@crometrics/lp-design-system/promo-banner';
Per-component entry points load only that component's modules in every tool:
production bundles, dev servers, and test runners alike. Importing from the package
root (import { Button } from '@crometrics/lp-design-system') also works and
tree-shakes in production builds, but dev servers and test runners load every
component through it.
import { Button } from '@crometrics/lp-design-system/button';
export default function Page() {
return (
<>
<Button>Primary</Button>
<Button variant="secondary" size="lg">
Secondary
</Button>
<Button variant="tertiary" icon={<CameraIcon />}>
With icon
</Button>
<Button
variant="transparent"
icon={<img alt="" src={logo} />}
iconPosition="trailing"
>
On a hero image
</Button>
<Button aria-label="Add photo" icon={<CameraIcon />} round />
</>
);
}
Server rendering and hydration
Every component server-renders (verified with renderToString on React 18 and 19).
Only two need client hydration — in HubSpot CMS React, render them as islands:
| Component | Why it hydrates |
|---|---|
DonationForm (and so DonationHero unless you pass formSlot) | Frequency/amount state |
Tout | Scroll-driven carousel indicator (mobileLayout="carousel") |
Everything else — including Header, TrustSignalsMetrics, ThirtyNinety,
PromoBanner, Accordion (Base UI hydrates its own primitive) and FooterMega —
ships no component JavaScript. With formSlot, DonationHero needs no hydration of
its own beyond whatever the slotted vendor embed brings.
Components are also RSC-safe: only Tout and DonationForm are marked
'use client', so everything else renders on the server in Next.js App Router and
ships no component JavaScript to the browser. Both can be rendered directly from a server
component — no wrapper file needed. Frameworks without RSC (Vite SPAs, Astro, plain
React) ignore the directive.
Props passed from a server component must be serializable: event handlers such as
Input's onChange or EmailSignupForm's onSubmit can only be supplied from a
client component.
Theming
--lp-* custom properties are the entire public styling API. Defaults are declared on
:root in styles.css; override any of them in your own CSS, loaded after the
library stylesheet:
:root {
--lp-color-primary: #16a34a;
}
Defaults mirror the Figma "Landing Page Design System" library.
| Variable | Default | Used for |
|---|---|---|
--lp-color-primary | #185dff | Primary button fill, tertiary/transparent border |
--lp-color-primary-hover | #0037b8 | Primary button hover fill |
--lp-color-text-on-primary | var(--lp-color-text-inverse) | Primary button label (e.g. dark text on an orange CTA) |
--lp-color-secondary | #141414 | Secondary button fill |
--lp-color-secondary-hover | #666666 | Secondary button hover fill |
--lp-color-secondary-active | #212121 | Secondary button pressed fill |
--lp-color-surface | #ffffff | Light surfaces (tertiary button fill) |
--lp-color-text | #141414 | Text on light surfaces |
--lp-color-text-inverse | #ffffff | Text on dark/brand surfaces |
--lp-color-text-inverse-muted | var(--lp-color-text-inverse) | Secondary text on dark surfaces |
--lp-color-text-inverse-brand | var(--lp-color-text-inverse) | Brand-colored text on dark surfaces |
--lp-color-text-inverse-accent | var(--lp-color-text-inverse) | Accent text/links on dark surfaces |
--lp-color-text-muted | #666666 | Disabled text |
--lp-color-border-muted | #b4b4b4 | Disabled borders |
--lp-color-disabled | #e5e5e5 | Disabled fills |
--lp-color-focus | #dc0173 | Focus-visible outline |
--lp-font-sans | system-ui, -apple-system, sans-serif | Body text |
--lp-font-button | "Montserrat", var(--lp-font-sans) | Button labels (load Montserrat yourself) |
--lp-font-button-weight | 500 | Button label weight |
--lp-font-cta-weight | 500 | Text-link CTA weight (e.g. card CTAs) |
--lp-font-badge-weight | 600 | Badge variant="tag" label weight |
--lp-radius | 0.375rem | Corner radius |
--lp-color-surface-secondary | #f6f6f6 | Section backgrounds (e.g. alternating band color) |
--lp-color-surface-tertiary | #e5e5e5 | Subtler section backgrounds |
--lp-color-surface-inverse | #141414 | Dark section backgrounds |
--lp-color-surface-inverse-secondary | var(--lp-color-surface-inverse) | Second dark surface (e.g. a card on a dark band) |
--lp-color-surface-tint | var(--lp-color-surface-secondary) | Tinted section band |
--lp-color-surface-brand-secondary | var(--lp-color-surface-tertiary) | Secondary brand-colored band/card |
--lp-color-surface-neutral | var(--lp-color-surface-secondary) | Warm/neutral section band |
--lp-color-surface-card | transparent | Card surface (e.g. ContentCard) |
--lp-color-surface-card-hover | var(--lp-color-surface-card) | Card surface on hover |
--lp-color-text-on-card / -hover | var(--lp-color-text) / var(--lp-color-text-on-card) | Text on a filled card, and on hover |
--lp-color-surface-inverse-brand-secondary | var(--lp-color-surface-brand-secondary) | Badge variant="tag" fill |
--lp-color-donation-icon | currentColor | DonationForm's Monthly heart icon |
--lp-color-surface-brand | #185dff | Badge fill, brand-colored surfaces |
--lp-color-text-brand | #185dff | Brand-colored text and links (e.g. card CTAs) |
--lp-color-border | #212121 | Stronger borders (vs. -border-muted) |
--lp-color-border-inverse | var(--lp-color-border-muted) | Borders/dividers on dark surfaces |
--lp-color-accent | #dc0173 | Brand accent color |
--lp-color-donation | #dc0173 | Donation-styled button fill (reuses accent) |
--lp-font-body | "Montserrat", var(--lp-font-sans) | Body text, badges, card CTAs |
--lp-font-heading | "Playfair Display", serif | Section/card headings (load the font yourself) |
--lp-font-heading-weight | unset (headings keep the browser/your default weight) | Weight of every h1–h5/CMS heading style |
--lp-text-{h1-cms,h2-cms,h1,h2,h3,h4,h5}-tracking | unset (inherit) | Letter-spacing per heading level (e.g. -1px) |
--lp-text-h1-cms-size / -line-height | 3rem / 3rem (48px/48px), 2.25rem/2.5rem mobile | Largest CMS page headings |
--lp-text-h2-cms-size / -line-height | 2.25rem/2.5rem (36px/40px), 1.875rem/2.25rem mobile | Second-largest CMS headings |
--lp-text-h1-size / -line-height | 2.25rem/2.5rem (36px/40px), 1.875rem/2.25rem mobile | H1 |
--lp-text-h2-size / -line-height | 1.5rem/2rem (24px/32px), 1.25rem/1.75rem mobile | H2 |
--lp-text-h3-size / -line-height | 1.25rem/1.75rem (20px/28px), 1.125rem/1.75rem mobile | H3 |
--lp-text-h4-size / -line-height | 1.125rem/1.75rem (18px/28px), 1rem/1.5rem mobile | H4 |
--lp-text-h5-size / -line-height | 1rem/1.5rem (16px/24px), 0.875rem/1.25rem mobile | H5 |
--lp-text-body-lg-size / -line-height | 1.125rem/1.75rem (18px/28px, no mobile variant) | Large body copy |
--lp-text-body-size / -line-height | 1rem/1.5rem (16px/24px, no mobile variant) | Body copy |
--lp-text-body-sm-size / -line-height | 0.875rem/1.25rem (14px/20px, no mobile variant) | Small body copy |
--lp-text-eyebrow-size / -line-height | 0.875rem/1.25rem (14px/20px, no mobile variant) | Eyebrow labels |
--lp-text-cta-size / -line-height | 0.875rem/1.25rem (14px/20px, no mobile variant) | Text-link CTAs (e.g. card CTAs) |
--lp-text-button-md-size / -line-height | 0.875rem/1.25rem (14px/20px) | Medium button labels |
--lp-text-button-lg-size / -line-height | 1rem/1.5rem (16px/24px) | Large button labels |
--lp-text-footer-size / -line-height | body-sm values | FooterMega copyright/legal text |
--lp-radius-card | 0.375rem | Card corner radius |
--lp-radius-input | 0.375rem | Input/textarea corner radius (same value as radius-card, independent token for future retheming) |
--lp-radius-image | 0px | Image corner radius (sharp by design) |
--lp-space-section | 6rem desktop / 4rem mobile | Section vertical padding |
--lp-space-gutter | 1.5rem, 7.5rem at 1728px+ (wide) | Section horizontal padding |
--lp-space-stack | 2rem | Gap between cards in Tout's desktop grid (via --lp-space-cards) |
--lp-space-cards | var(--lp-space-stack) | Gap between cards in a card row (Tout) |
--lp-container | 93rem (1488px) | Max content width (excluding gutters), centered |
--lp-shadow | 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px 0 rgb(0 0 0 / 0.06) | Default elevation |
--lp-shadow-lg | 0 8px 10px -3px rgb(0 0 0 / 0.2), 0 4px 5px -2px rgb(0 0 0 / 0.15) | Raised elevation (e.g. hover) |
--lp-shadow-form | var(--lp-shadow-lg) | DonationForm card (and DonationHero's formSlot card) |
Breakpoints
Layouts follow the design's three page frames: mobile < 1024px, desktop 1024–1727px, wide ≥ 1728px. Responsive token defaults switch at those widths, and themes override responsive tokens inside the same media queries:
:root {
--lp-text-h1-cms-size: 2.5rem;
} /* mobile + desktop */
@media (min-width: 1728px) {
:root {
--lp-text-h1-cms-size: 4rem;
} /* wide */
}
Before 0.5.0 the wide tier started at 1280px.
The stylesheet never resets your page. It does reset inside its own components: every
component root carries a data-lp attribute, and a zero-specificity reset (Tailwind's
preflight, scoped to [data-lp] subtrees) clears browser defaults there, such as heading
and paragraph margins, link color and underline, and native button borders. The reset sits
in the lp-base cascade layer and the utility classes in lp. Any unlayered CSS on your
page outranks both, so a global rule like h2 { margin-bottom: 1rem } in your theme still
reaches our headings. Scope such rules away from [data-lp] if they interfere. Class
names other than these variables are implementation detail and may change in any release.
Components
| Component | Props |
|---|---|
Button | variant?: 'primary' | 'secondary' | 'tertiary' | 'transparent', size?: 'md' | 'lg', icon?: ReactNode (an <img> or inline SVG), iconPosition?: 'leading' | 'trailing', round?: boolean, href?: string (renders as <a href> instead of <button> when set), newTab?: boolean (adds target="_blank" rel="noopener noreferrer"; only meaningful with href), plus all <button>/<a> props. Omit children for an icon-only button (give it an aria-label). |
Every section's full CMS-facing shape (props, toggles, repeaters, variants, slots) is in
contracts/<Name>.json. Additions in 0.5.0, all optional with defaults that keep the
previous rendering:
| Component | New props |
|---|---|
Header | ctas (1–2 { link: CtaLink; hideOnMobile? }; cta still works), logoSize: 'md' | 'lg' |
DonationHero | layout: 'overlay' | 'split', story: { badge?, heading?, body? } + showStory, formSlot (replaces the embedded DonationForm in the same card, e.g. with a third-party donation form such as a FundraiseUp embed; form then optional) |
DonationForm | amountsLabel, appearance: 'default' | 'tint', customAmountPlaceholder, trustLabel + showTrustLabel |
TrustSignalsMetrics | sectionHeading, headingLevel, sectionBody + showSectionBody, cta: CtaLink + showCta, style: 'compact' | 'cards'; per metric icon + showIcon, body + showBody |
Tout | headerAlign: 'left' | 'center', mobileLayout: 'carousel' | 'stack' |
ContentCard | body + showBody, surface: 'none' | 'filled' (filled = token-backed surface with hover state, whole card clickable), mediaAspectRatio (adds '5:4') |
ThirtyNinety (new, /thirty-ninety) | Figma "CMS 30-90": square media beside heading, body + showBody, cta: CtaLink + showCta, ctaFullWidthMobile, background, side, headingLevel |
PromoBanner | contentStyle: 'plain' | 'card', background (Solid: 'secondary' | 'inverse' | 'inverse-secondary'), size: 'default' | 'large', ctaVariant, ctaSize; cta is now a CtaLink |
Accordion / AccordionItem | contentStyle: 'plain' | 'card' |
FooterMega | style: 'default' | 'inverse', signupHeading + headingLevel, signupSlot + signupFormSelectors (see FooterMega signup form), legalLine, logoSize; description is now optional |
Badge | variant: 'pill' | 'tag' |
Additions in 0.6.0, also optional with defaults that keep the previous rendering:
| Component | New props |
|---|---|
DonationHero, EmailSignupHero | mediaPosition: 'top' | 'center' | 'bottom': which edge of the photo stays in view when its box crops it (default center) |
DonationHero | mediaMinAspect: 'none' | 'square' (split only): square keeps the photo panel at least as tall as it is wide, so wide viewports and the < 1024 range don't crop it to a thin band; on desktop the copy panel stretches to match |
PromoBanner | mediaPosition (Photo style), as above |
Input | size: 'md' | 'lg', hideLabel |
Additions in 0.7.0, also optional with defaults that keep the previous rendering:
| Component | New props |
|---|---|
DonationHero, EmailSignupHero, PromoBanner | mediaPosition also takes 'left', 'right' and the corners ('top-left', 'top-right', 'bottom-left', 'bottom-right') |
PromoBanner | Photo style: mediaDesktop / mediaMobile, art-directed crops of media for 1024–1727px and under 1024px (rendered with <picture>; mediaDesktop also covers mobile when mediaMobile is unset) |
CtaLink ({ href, label, newTab?, id?, dataAttributes? }) is the CTA shape for
Header, TrustSignalsMetrics, ThirtyNinety and PromoBanner. id and
dataAttributes (keys must start with data-; others are dropped) are rendered on the
CTA element, so your own scripts can target it, e.g. for analytics or to open a
third-party donation form such as FundraiseUp:
<Header
logo={logo}
ctas={[
{
link: {
label: 'Get started',
href: '#signup',
dataAttributes: { 'data-track': 'header-primary' },
},
hideOnMobile: true,
},
{ link: { label: 'Contact us', href: '#contact', id: 'header-contact' } },
]}
/>;
FooterMega signup form
FooterMega renders whatever form you pass as signupSlot: a CMS form module, an
email-provider embed, or your own markup. It can't know that markup, so
signupFormSelectors tells it which of your elements gets each part of the signup design.
Every key is optional and takes any CSS selector list, matched inside that footer's slot
only. Elements no key matches inherit the slot's font, size and color.
| Key | Gets |
|---|---|
form | Two-column grid; every child spans both columns |
fieldRow | Its children side by side, equal width |
field | Text-entry styling (inputs, selects, textareas), placeholder, focus ring |
fieldInvalid | Error border color |
hiddenLabel | Visually hidden, still read by screen readers (configure placeholders) |
hint | Secondary text: small, muted |
error | Validation message: small, error color, no list bullets |
choice | Checkbox/radio accent color and size |
submit | Primary button |
inlineField | A form child that shares the last row with inlineSubmit (needs form) |
inlineSubmit | The form child holding the submit, beside inlineField (needs form) |
reset | Margins and padding removed (wrappers whose own CSS fights the layout) |
For example, for a form module whose markup uses .hs-* classes, as HubSpot's does:
<FooterMega
{...footerProps}
signupFormSelectors={{
error: '.hs-error-msgs',
field: '.hs-input:not([type=checkbox], [type=radio])',
fieldInvalid: '.hs-input.error',
fieldRow: 'fieldset',
form: '.hs-form',
hiddenLabel:
'.hs-form-field:not(.hs-fieldtype-checkbox, .hs-fieldtype-booleancheckbox, .hs-fieldtype-radio) > label',
hint: '.hs-richtext',
inlineField: 'fieldset:last-of-type',
inlineSubmit: '.hs-submit',
reset: '.input, .actions',
submit: '.hs-button',
}}
signupSlot={<YourFormModule />}
/>
The footer renders these rules in a <style> inside the slot, in the lp layer, so your
own CSS still overrides them; the few layout declarations that embeds' own stylesheets
usually fight (widths, floats, wrapper margins, hidden labels) are !important. Colors
follow style and the --lp-* variables. A selector containing {, }, ;, < or a
comment throws. For an element that needs a look none of these keys gives, style it in
your own CSS under [data-lp-part="signup-slot"] with the --lp-* variables.
Typed contracts
contracts/<Name>.json (described by contracts/component.schema.json) widens every
literal when imported as JSON. @crometrics/lp-design-system/contracts exports the same
data with its literal types, as <Name>Contract, plus the types to work with it:
import {
type ContractProp,
type ContractValues,
HeaderContract,
} from '@crometrics/lp-design-system/contracts';
// Props, variants and repeater items share one union, discriminated on `type`.
const field = (prop: ContractProp) => {
switch (prop.type) {
case 'enum':
return prop.options; // readonly [string, ...string[]]
case 'link':
return prop.fields; // subset of 'label' | 'href' | 'newTab' | 'id' | 'dataAttributes'
// …
}
};
// The props a contract describes: { logo: { src; alt }; logoSize?: 'md' | 'lg'; … }
type HeaderValues = ContractValues<typeof HeaderContract>;
ContractValues maps text to string, boolean, number, enum to its options,
image to { src, alt } and link to Pick<CtaLink, …fields>. A richtext prop
follows its render mode: html props are injected with dangerouslySetInnerHTML, so
they take the CMS's HTML string; node props are rendered as a React child, so they
take a ReactNode. Turn the HTML into elements for those (a raw HTML string would show
escaped). A repeater becomes an array: a required non-empty tuple [T, ...T[]] when
min ≥ 1, else optional. Dotted keys (form.submitLabel) become nested objects.
required controls whether a key is optional, and variants are always optional.
hidden props and slots are left out (a slot is a ReactNode prop, like formSlot or
signupSlot, that you render yourself), and so are countFrom variants such as
BannerGrid's quantity: they only size their repeater, and the component reads the
array's length. Also exported: Contract, ContractVariant, ContractRepeater,
ContractSlot, ContractPropValue<P>, ContractRichtextRender, and one interface per
prop type (TextProp, RichtextProp<Render>, EnumProp, LinkProp, …).
mapContract
@crometrics/lp-design-system/contracts/map walks a contract for you. You supply one
typed reader per leaf type, and it returns ContractValues<C>, with no assertion on
your side:
import { ToutContract } from '@crometrics/lp-design-system/contracts';
import {
type ContractReaders,
mapContract,
} from '@crometrics/lp-design-system/contracts/map';
const text = (value: unknown) =>
typeof value === 'string' ? value : undefined;
const read: ContractReaders = {
boolean: (value) => (typeof value === 'boolean' ? value : undefined),
enum: text, // mapContract drops values that aren't one of the options
image: (value) => toImage(value),
link: (value) => toCtaLink(value),
number: (value) => (typeof value === 'number' ? value : undefined),
richtext: {
html: text, // must return a string
node: (value) =>
typeof value === 'string' ? htmlToReact(value) : undefined,
},
text: (value, prop) => text(value) ?? prop.default,
};
const props = mapContract(ToutContract, fieldValues, read); // ToutProps-compatible
Each reader gets the raw value, the prop's contract entry and its path (sectionHeading,
cards[1].body, variants.cards). fieldValues mirrors the contract: props and
repeaters sit at their dotted keys ({ form: { submitLabel } }), repeaters are arrays of
item objects, and variants go under variants, the same way countFrom names them
({ cards: […], variants: { cards: '3' } }). Hidden and slot props are skipped. A
countFrom repeater is trimmed to its variant's value (or its default). A reader that
returns undefined leaves the prop out, so the component default applies. For a
required prop, or a min ≥ 1 repeater that ends up empty, mapContract throws
instead, because the props type promises a value. Fall back to prop.default in the
reader to avoid that.
A few contracts don't describe their component's props exactly, so the CMS adapter has
to map these fields itself. contracts/props.test-d.ts pins each one, and any other
difference between a contract and its component fails the type tests:
| Contract | Field | Component takes |
|---|---|---|
Button | style, label, icon (icon name) | variant, children, icon: ReactNode |
AspectRatioImage | src (image object) | src: string + alt; aspectRatio is required |
DonationForm (and DonationHero's form.*) | amounts: [{ amount }, …], paymentIcons: { image? }[] | amounts: number[] (amounts.map((item) => item.amount)), paymentIcons: { alt, src }[] (drop items without an image, then take image) |
AccordionItem | an Accordion items[] entry (defaultOpen) | Accordion supplies value |
PromoBanner | mediaDesktop.alt, mediaMobile.alt | nothing: the crops reuse media.alt |
Development
Run from this package directory:
pnpm storybook # dev at http://localhost:6006
pnpm test # runs the stories headless via addon-vitest
pnpm build # dist/*.js per component (tsdown) + dist/styles.css (@tailwindcss/cli)
Stories are the tests: every component behavior is asserted in a *.stories.tsx
play function rather than a separate spec file.