Skip to content

Pine: A Case Study

Pine is Kajabi's design system. It's a library of web components that don't care which framework a page is built in, sitting on top of design tokens and an icon library, with docs written for people and AI tools. It's run by a small core team, and honestly, that shaped most of the decisions behind...

Phillip Lovelace
Oct 14, 20266 min read

ine is Kajabi’s design system. It’s a library of web components that don’t care which framework a page is built in, sitting on top of design tokens and an icon library, with docs written for people and AI tools.

It’s run by a small core team, and honestly, that shaped most of the decisions behind it. When there aren’t many of you, you can’t review your way to consistency, so the system has to enforce it. I’ve been one of its core maintainers since 2023.

Role
Sr. UX Developer, core maintainer
Company
Kajabi · 2020 to present
On Pine
2023 to present
Focus
Components, tokens, icons and AI tooling
  • 36 components
  • 4 connected repos
  • 40+ engineers supported
  • 4 major versions

The problem

Products rarely stay on one framework, and Kajabi’s doesn’t. A component library tied to one framework means building every component again for each of the others, then keeping the copies in sync. They never stay in sync. Buttons end up close-but-not-quite, and so does the rest of the UI.

One implementation, multiple frameworks

Pine is built from web components, custom HTML elements the browser understands on its own. The same component works in any framework, and Pine’s React wrappers are generated from it rather than written by hand. Here’s the same button in a Rails view and a React app:

Rails view

<pds-button>Save</pds-button>

React app

<PdsButton>Save</PdsButton>

A fix ships once and lands everywhere the component is used. Underneath it all are design tokens, the named values for color, spacing and type. Tokens are defined in Figma and flow one way into code, so a token change reaches every component without anyone copying values across by hand.

How Pine is built and delivered Figma is the design source. Design tokens (@kajabi-ui/styles) and icons (@pine-ds/icons) are exported from Figma and feed @pine-ds/core, 36 web components. Core ships to Rails views through npm or a CDN, and generates @pine-ds/react wrappers for React apps. A knowledge layer explains the system: Figma Code Connect shows Pine code in Figma Dev Mode, Storybook docs are generated from the component source, and the Pine MCP server serves the same docs plus tokens and icons to AI coding agents, which write Pine UI in the product apps. /* Site tokens, so the figure matches the theme and its toggle (inlined by the case_figure shortcode) */ .pa { --bg:var(--site-bg-alt); --band:color-mix(in srgb, var(--site-bg-alt) 50%, var(--site-bg)); --box:var(--site-bg); --stroke:var(--divider-color); --ink:var(--text-color); --ink2:var(--text-muted); --arrow:var(--text-muted); --accent:light-dark(#2a78d6, #3987e5); --wash:light-dark(rgba(42,120,214,0.08), rgba(57,135,229,0.16)); font-family: inherit; } .pa-bg { fill: var(--bg); } .pa-band { fill: var(--band); } .pa-box { fill: var(--box); stroke: var(--stroke); stroke-width: 1; } .pa-core { fill: var(--wash); stroke: var(--accent); stroke-width: 2; } .pa-line { fill: none; stroke: var(--arrow); stroke-width: 1.5; } .pa-dash { stroke-dasharray: 5 4; } .pa-head { fill: var(--arrow); } .pa-eyebrow { fill: var(--ink2); font-size: 11px; font-weight: 600; letter-spacing: 0.08em; } .pa-title { fill: var(--ink); font-size: 14px; font-weight: 600; text-anchor: middle; } .pa-mono { fill: var(--ink); font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 13.5px; font-weight: 600; text-anchor: middle; } .pa-core-title { font-size: 15px; } .pa-code { fill: var(--ink2); font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; text-anchor: middle; } .pa-sub { fill: var(--ink2); font-size: 12px; text-anchor: middle; } .pa-small { fill: var(--ink2); font-size: 11.5px; text-anchor: middle; } .pa-label { fill: var(--ink2); font-size: 11.5px; } .pa-mid { text-anchor: middle; } <rect class="pa-bg" x="0" y="0" width="760" height="724"/> <rect class="pa-band" x="500" y="16" width="240" height="660" rx="12"/> <text class="pa-eyebrow" x="24" y="42">BUILD &amp; DELIVERY</text> <text class="pa-eyebrow" x="516" y="42">KNOWLEDGE LAYER</text> <rect class="pa-box" x="24" y="60" width="424" height="60" rx="10"/> <text class="pa-title" x="236" y="86">Figma</text> <text class="pa-sub" x="236" y="106">design source: variables, icons, components</text> <rect class="pa-box" x="516" y="60" width="208" height="60" rx="10"/> <text class="pa-title" x="620" y="86">Figma Code Connect</text> <text class="pa-sub" x="620" y="106">Pine code in Dev Mode</text> <line class="pa-line pa-dash" x1="516" y1="90" x2="449" y2="90" marker-end="url(#pa-arrow)"/> <line class="pa-line" x1="125" y1="120" x2="125" y2="175" marker-end="url(#pa-arrow)"/> <text class="pa-label" x="133" y="152">Tokens Studio JSON</text> <line class="pa-line" x1="347" y1="120" x2="347" y2="175" marker-end="url(#pa-arrow)"/> <text class="pa-label" x="355" y="152">SVG export</text> <rect class="pa-box" x="24" y="176" width="202" height="80" rx="10"/> <text class="pa-mono" x="125" y="204">@kajabi-ui/styles</text> <text class="pa-sub" x="125" y="224">design tokens</text> <text class="pa-small" x="125" y="243">core → semantic → component</text> <rect class="pa-box" x="246" y="176" width="202" height="80" rx="10"/> <text class="pa-mono" x="347" y="204">@pine-ds/icons</text> <text class="pa-sub" x="347" y="224">~395 SVG icons</text> <text class="pa-small" x="347" y="243">lazy-loaded pds-icon</text> <line class="pa-line" x1="125" y1="256" x2="125" y2="311" marker-end="url(#pa-arrow)"/> <text class="pa-label" x="133" y="288">--pine-* CSS variables</text> <line class="pa-line" x1="347" y1="256" x2="347" y2="311" marker-end="url(#pa-arrow)"/> <text class="pa-label" x="355" y="288">pds-icon</text> <rect class="pa-core" x="24" y="312" width="424" height="80" rx="10"/> <text class="pa-mono pa-core-title" x="236" y="342">@pine-ds/core</text> <text class="pa-sub" x="236" y="362">36 web components</text> <text class="pa-small" x="236" y="381">one implementation for every framework</text> <rect class="pa-box" x="516" y="312" width="208" height="80" rx="10"/> <text class="pa-title" x="620" y="342">Storybook docs</text> <text class="pa-sub" x="620" y="362">docs and prop tables</text> <text class="pa-small" x="620" y="381">generated from source</text> <line class="pa-line pa-dash" x1="448" y1="352" x2="515" y2="352" marker-end="url(#pa-arrow)"/> <text class="pa-label pa-mid" x="482" y="344">docs.json</text> <line class="pa-line" x1="347" y1="392" x2="347" y2="447" marker-end="url(#pa-arrow)"/> <text class="pa-label" x="355" y="424">generated at build</text> <line class="pa-line" x1="125" y1="392" x2="125" y2="581" marker-end="url(#pa-arrow)"/> <text class="pa-label" x="133" y="490">npm or CDN</text> <rect class="pa-box" x="246" y="448" width="202" height="80" rx="10"/> <text class="pa-mono" x="347" y="478">@pine-ds/react</text> <text class="pa-sub" x="347" y="498">React wrappers</text> <text class="pa-small" x="347" y="517">generated, not hand-written</text> <line class="pa-line pa-dash" x1="620" y1="392" x2="620" y2="447" marker-end="url(#pa-arrow)"/> <text class="pa-label" x="628" y="416">same docs data,</text> <text class="pa-label" x="628" y="431">+ tokens, icons</text> <rect class="pa-box" x="516" y="448" width="208" height="80" rx="10"/> <text class="pa-title" x="620" y="478">Pine MCP server</text> <text class="pa-sub" x="620" y="498">components, tokens, icons</text> <text class="pa-small" x="620" y="517">and Pine's layout rules</text> <line class="pa-line" x1="347" y1="528" x2="347" y2="581" marker-end="url(#pa-arrow)"/> <text class="pa-label" x="355" y="560">npm</text> <rect class="pa-box" x="24" y="582" width="202" height="80" rx="10"/> <text class="pa-title" x="125" y="612">Rails views</text> <text class="pa-code" x="125" y="634">&lt;pds-button&gt;</text> <rect class="pa-box" x="246" y="582" width="202" height="80" rx="10"/> <text class="pa-title" x="347" y="612">React apps</text> <text class="pa-code" x="347" y="634">&lt;PdsButton /&gt;</text> <line class="pa-line pa-dash" x1="620" y1="528" x2="620" y2="581" marker-end="url(#pa-arrow)"/> <text class="pa-label" x="628" y="560">MCP tools</text> <rect class="pa-box" x="516" y="582" width="208" height="80" rx="10"/> <text class="pa-title" x="620" y="612">AI coding agents</text> <text class="pa-sub" x="620" y="634">look up, generate, validate</text> <line class="pa-line" x1="516" y1="622" x2="449" y2="622" marker-end="url(#pa-arrow)"/> <text class="pa-label pa-mid" x="482" y="614">write UI</text> <line class="pa-line" x1="24" y1="704" x2="58" y2="704" marker-end="url(#pa-arrow)"/> <text class="pa-label" x="66" y="708">code and styles</text> <line class="pa-line pa-dash" x1="184" y1="704" x2="218" y2="704" marker-end="url(#pa-arrow)"/> <text class="pa-label" x="226" y="708">docs and lookups</text>
How Pine fits together. Solid arrows carry code and styles; dashed arrows carry documentation and lookups.

What the system is built on

Pine is built in layers, and each one has a single job. Layout arranges the page. Components handle what happens inside it. Tokens style both. Patterns come from combining those small pieces, not from building one-offs.

Layout for composition

Box and row primitives arrange everything on the page. Their spacing only accepts values from the scale, so patterns compose cleanly and off-scale gaps can’t be written at all.

Primitive components

Buttons, inputs, tables, alerts and the rest handle interaction, state and accessibility. They drop into any layout, so building a pattern is mostly a matter of combining them.

Tokens for style

Color, spacing and type come from tokens in three tiers: core, semantic and component. Themes like dark mode swap the semantic values, and hard-coded colors are flagged before merge.

Readable by AI tools

Agent instructions and an MCP server, a standard way for AI tools to call outside services, let coding tools look up real components and check their output, instead of inventing them.


A screen built only from Pine components. Dark mode comes from the same tokens, with no extra code.

Documentation people actually use

A component nobody can figure out doesn’t get used. Pine’s docs site covers every component with usage guidelines, live examples and property tables generated from the source, so the reference can’t drift from the code.

Around the components sit guides for the things teams get stuck on, like forms, accessibility, events, motion and right-to-left layouts, plus getting-started and contributing docs, a changelog and migration guides between major versions. The same content feeds the MCP server, so people and AI tools read from one source.

Pine’s documentation site: guidelines, live examples and generated property tables for every component.

My contributions

I’ve been a core maintainer since 2023, and I’ve worked in every layer of the system. I’m also one of the first people engineers and designers come to when they’re building with it. Pine is a team effort, and most of what’s here was shaped together. These are the areas where I’ve had the biggest hand.

Developer support

Answering questions, unblocking teams, reviewing their code and turning the questions that keep coming up into better docs.

Documentation

Writing and maintaining the docs: component guidelines, live examples and the guides teams lean on most. Then keeping all of it current.

Components

Building and maintaining components, simple form controls and big data-heavy ones alike, through each major version.

Foundations

Working on the layout, tokens and icons that every component sits on, and keeping them consistent as the system kept growing.

AI tooling

Starting the Pine MCP server and the first AI agent instructions, and growing both as AI tools became part of daily work.

Accessibility

Fixing accessibility issues across the system, like form errors screen readers never announced and low contrast in the dark theme.

The Pine MCP server, documented next to the components it describes.

Outcomes

Built once

Every component is built once and works in any framework, with React wrappers generated instead of hand-written.

Small team, real coverage

Four major versions and over a thousand automated tests since 2024, kept up by a small team with automated checks.

Growing adoption

Pine use across Kajabi keeps growing, with more of the product’s UI built from its components every year.

What I learned

Automate the rules you care about

With a small team, anything enforced only in review slips through. Checks turn conventions into failing builds, and anything with a source of truth, like the token docs, should be generated rather than written twice.

Slots for content, props for config

Letting people pass their own markup into a component, instead of strings, made components far easier to compose. Settings stay as props. Adopting the rule later meant a breaking change, so it’s worth deciding early.

AI tools are a real audience

Coding agents have a limited reading budget, stumble on near-miss names and never notice missing data. Designing for them meant short answers, forgiving lookups and failing loudly, which made the docs better for people too.

A ratchet only helps if someone turns it

Pine’s accessibility check blocks new problems, while existing ones were recorded in a baseline so the check could switch on at all. Paying that baseline down, one fix at a time, is the part that takes discipline.

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions