@crometrics/lp-design-system

中文文档
0.10.1发布于 2026/11/11UNLICENSEDES Module近 30 天下载 2035
原始使用文档
文档目录9 节
  1. Install
  2. Usage
  3. Server rendering and hydration
  4. Theming
  5. Breakpoints
  6. Components
  7. FooterMega signup form
  8. Typed contracts
  9. Development

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

ComponentWhy it hydrates
DonationForm (and so DonationHero unless you pass formSlot)Frequency/amount state
ToutScroll-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.

VariableDefaultUsed for
--lp-color-primary#185dffPrimary button fill, tertiary/transparent border
--lp-color-primary-hover#0037b8Primary button hover fill
--lp-color-text-on-primaryvar(--lp-color-text-inverse)Primary button label (e.g. dark text on an orange CTA)
--lp-color-secondary#141414Secondary button fill
--lp-color-secondary-hover#666666Secondary button hover fill
--lp-color-secondary-active#212121Secondary button pressed fill
--lp-color-surface#ffffffLight surfaces (tertiary button fill)
--lp-color-text#141414Text on light surfaces
--lp-color-text-inverse#ffffffText on dark/brand surfaces
--lp-color-text-inverse-mutedvar(--lp-color-text-inverse)Secondary text on dark surfaces
--lp-color-text-inverse-brandvar(--lp-color-text-inverse)Brand-colored text on dark surfaces
--lp-color-text-inverse-accentvar(--lp-color-text-inverse)Accent text/links on dark surfaces
--lp-color-text-muted#666666Disabled text
--lp-color-border-muted#b4b4b4Disabled borders
--lp-color-disabled#e5e5e5Disabled fills
--lp-color-focus#dc0173Focus-visible outline
--lp-font-sanssystem-ui, -apple-system, sans-serifBody text
--lp-font-button"Montserrat", var(--lp-font-sans)Button labels (load Montserrat yourself)
--lp-font-button-weight500Button label weight
--lp-font-cta-weight500Text-link CTA weight (e.g. card CTAs)
--lp-font-badge-weight600Badge variant="tag" label weight
--lp-radius0.375remCorner radius
--lp-color-surface-secondary#f6f6f6Section backgrounds (e.g. alternating band color)
--lp-color-surface-tertiary#e5e5e5Subtler section backgrounds
--lp-color-surface-inverse#141414Dark section backgrounds
--lp-color-surface-inverse-secondaryvar(--lp-color-surface-inverse)Second dark surface (e.g. a card on a dark band)
--lp-color-surface-tintvar(--lp-color-surface-secondary)Tinted section band
--lp-color-surface-brand-secondaryvar(--lp-color-surface-tertiary)Secondary brand-colored band/card
--lp-color-surface-neutralvar(--lp-color-surface-secondary)Warm/neutral section band
--lp-color-surface-cardtransparentCard surface (e.g. ContentCard)
--lp-color-surface-card-hovervar(--lp-color-surface-card)Card surface on hover
--lp-color-text-on-card / -hovervar(--lp-color-text) / var(--lp-color-text-on-card)Text on a filled card, and on hover
--lp-color-surface-inverse-brand-secondaryvar(--lp-color-surface-brand-secondary)Badge variant="tag" fill
--lp-color-donation-iconcurrentColorDonationForm's Monthly heart icon
--lp-color-surface-brand#185dffBadge fill, brand-colored surfaces
--lp-color-text-brand#185dffBrand-colored text and links (e.g. card CTAs)
--lp-color-border#212121Stronger borders (vs. -border-muted)
--lp-color-border-inversevar(--lp-color-border-muted)Borders/dividers on dark surfaces
--lp-color-accent#dc0173Brand accent color
--lp-color-donation#dc0173Donation-styled button fill (reuses accent)
--lp-font-body"Montserrat", var(--lp-font-sans)Body text, badges, card CTAs
--lp-font-heading"Playfair Display", serifSection/card headings (load the font yourself)
--lp-font-heading-weightunset (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}-trackingunset (inherit)Letter-spacing per heading level (e.g. -1px)
--lp-text-h1-cms-size / -line-height3rem / 3rem (48px/48px), 2.25rem/2.5rem mobileLargest CMS page headings
--lp-text-h2-cms-size / -line-height2.25rem/2.5rem (36px/40px), 1.875rem/2.25rem mobileSecond-largest CMS headings
--lp-text-h1-size / -line-height2.25rem/2.5rem (36px/40px), 1.875rem/2.25rem mobileH1
--lp-text-h2-size / -line-height1.5rem/2rem (24px/32px), 1.25rem/1.75rem mobileH2
--lp-text-h3-size / -line-height1.25rem/1.75rem (20px/28px), 1.125rem/1.75rem mobileH3
--lp-text-h4-size / -line-height1.125rem/1.75rem (18px/28px), 1rem/1.5rem mobileH4
--lp-text-h5-size / -line-height1rem/1.5rem (16px/24px), 0.875rem/1.25rem mobileH5
--lp-text-body-lg-size / -line-height1.125rem/1.75rem (18px/28px, no mobile variant)Large body copy
--lp-text-body-size / -line-height1rem/1.5rem (16px/24px, no mobile variant)Body copy
--lp-text-body-sm-size / -line-height0.875rem/1.25rem (14px/20px, no mobile variant)Small body copy
--lp-text-eyebrow-size / -line-height0.875rem/1.25rem (14px/20px, no mobile variant)Eyebrow labels
--lp-text-cta-size / -line-height0.875rem/1.25rem (14px/20px, no mobile variant)Text-link CTAs (e.g. card CTAs)
--lp-text-button-md-size / -line-height0.875rem/1.25rem (14px/20px)Medium button labels
--lp-text-button-lg-size / -line-height1rem/1.5rem (16px/24px)Large button labels
--lp-text-footer-size / -line-heightbody-sm valuesFooterMega copyright/legal text
--lp-radius-card0.375remCard corner radius
--lp-radius-input0.375remInput/textarea corner radius (same value as radius-card, independent token for future retheming)
--lp-radius-image0pxImage corner radius (sharp by design)
--lp-space-section6rem desktop / 4rem mobileSection vertical padding
--lp-space-gutter1.5rem, 7.5rem at 1728px+ (wide)Section horizontal padding
--lp-space-stack2remGap between cards in Tout's desktop grid (via --lp-space-cards)
--lp-space-cardsvar(--lp-space-stack)Gap between cards in a card row (Tout)
--lp-container93rem (1488px)Max content width (excluding gutters), centered
--lp-shadow0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px 0 rgb(0 0 0 / 0.06)Default elevation
--lp-shadow-lg0 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-formvar(--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

ComponentProps
Buttonvariant?: '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:

ComponentNew props
Headerctas (1–2 { link: CtaLink; hideOnMobile? }; cta still works), logoSize: 'md' | 'lg'
DonationHerolayout: '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)
DonationFormamountsLabel, appearance: 'default' | 'tint', customAmountPlaceholder, trustLabel + showTrustLabel
TrustSignalsMetricssectionHeading, headingLevel, sectionBody + showSectionBody, cta: CtaLink + showCta, style: 'compact' | 'cards'; per metric icon + showIcon, body + showBody
ToutheaderAlign: 'left' | 'center', mobileLayout: 'carousel' | 'stack'
ContentCardbody + 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
PromoBannercontentStyle: 'plain' | 'card', background (Solid: 'secondary' | 'inverse' | 'inverse-secondary'), size: 'default' | 'large', ctaVariant, ctaSize; cta is now a CtaLink
Accordion / AccordionItemcontentStyle: 'plain' | 'card'
FooterMegastyle: 'default' | 'inverse', signupHeading + headingLevel, signupSlot + signupFormSelectors (see FooterMega signup form), legalLine, logoSize; description is now optional
Badgevariant: 'pill' | 'tag'

Additions in 0.6.0, also optional with defaults that keep the previous rendering:

ComponentNew props
DonationHero, EmailSignupHeromediaPosition: 'top' | 'center' | 'bottom': which edge of the photo stays in view when its box crops it (default center)
DonationHeromediaMinAspect: '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
PromoBannermediaPosition (Photo style), as above
Inputsize: 'md' | 'lg', hideLabel

Additions in 0.7.0, also optional with defaults that keep the previous rendering:

ComponentNew props
DonationHero, EmailSignupHero, PromoBannermediaPosition also takes 'left', 'right' and the corners ('top-left', 'top-right', 'bottom-left', 'bottom-right')
PromoBannerPhoto 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.

KeyGets
formTwo-column grid; every child spans both columns
fieldRowIts children side by side, equal width
fieldText-entry styling (inputs, selects, textareas), placeholder, focus ring
fieldInvalidError border color
hiddenLabelVisually hidden, still read by screen readers (configure placeholders)
hintSecondary text: small, muted
errorValidation message: small, error color, no list bullets
choiceCheckbox/radio accent color and size
submitPrimary button
inlineFieldA form child that shares the last row with inlineSubmit (needs form)
inlineSubmitThe form child holding the submit, beside inlineField (needs form)
resetMargins 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:

ContractFieldComponent takes
Buttonstyle, label, icon (icon name)variant, children, icon: ReactNode
AspectRatioImagesrc (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)
AccordionIteman Accordion items[] entry (defaultOpen)Accordion supplies value
PromoBannermediaDesktop.alt, mediaMobile.altnothing: 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.

版本与发布记录

@crometrics/lp-design-system 共发布 13 个版本,首次发布于 2026/10/23;过去 12 个月发布了 13 个版本。

版本发布日期距今
0.10.1latest2026/11/113 天前
0.10.02026/11/113 天前
0.9.32026/11/077 天前
0.9.22026/11/077 天前
0.9.12026/11/0410 天前
0.9.02026/10/2421 天前

关于 @crometrics/lp-design-system 的常见问题

如何安装 @crometrics/lp-design-system?

在项目目录运行 npm install @crometrics/lp-design-system,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(^18.0.0 || ^19.0.0)、react-dom(^18.0.0 || ^19.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@crometrics/lp-design-system 支持 TypeScript 吗?

该包的 package.json 没有声明类型入口。需要类型提示时,可以在 npm 上查找社区维护的 @types/crometrics__lp-design-system 是否存在。

@crometrics/lp-design-system 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@crometrics/lp-design-system 可以商用吗?

@crometrics/lp-design-system 使用 UNLICENSED 许可。作者声明为 UNLICENSED,即不授予公开使用许可(与公共领域的 Unlicense 不同)。使用前需获得作者授权。以上为概括,不构成法律意见,请以许可原文为准。

@crometrics/lp-design-system 的下载量有多少?

根据 npm 官方下载统计,@crometrics/lp-design-system 在 2026/10/11 – 2026/11/09 这 30 天内被下载 2,035 次,日均约 68 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@crometrics/lp-design-system 还在维护吗?

最新版本 0.10.1 发布于 2026/11/11(3 天前);至今共发布 13 个版本,其中过去 12 个月发布了 13 个;首次发布于 2026/10/23。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。