Skip to content

UI Components Package

Shared UI components and design tokens in the @jubiloop/ui package, built with shadcn/ui patterns and Tailwind CSS 4.

Overview

The UI package owns reusable components, semantic design tokens, shared base styles, and the PostCSS configuration used by the webapp and marketing site. Applications consume the package as source; it has no separate build step.

Package Structure

PathResponsibility
packages/ui/src/componentsShared React components
packages/ui/src/hooksShared UI hooks
packages/ui/src/libUtilities, including configured class merging
packages/ui/src/stylesTokens, mappings, base rules, and shared utilities
packages/ui/scriptsPackage contract and stylesheet smoke tests

package.json defines the supported public exports. Internal files that do not match those export patterns are not part of the consumer contract.

Package Exports

Components, hooks, and library helpers use wildcard subpath exports:

text
@jubiloop/ui/components/*  -> src/components/*.tsx
@jubiloop/ui/hooks/*       -> src/hooks/*.ts or src/hooks/*.tsx
@jubiloop/ui/lib/*         -> src/lib/*.ts
@jubiloop/ui/styles.css    -> src/styles/index.css
@jubiloop/ui/postcss.config -> postcss.config.mjs

Using Components

Consumers import the module they need rather than a package-wide barrel:

tsx
import { Button } from '@jubiloop/ui/components/button'
import { Card } from '@jubiloop/ui/components/card'
import { Input } from '@jubiloop/ui/components/input'
import { Label } from '@jubiloop/ui/components/label'

Component modules use named exports. Host-element wrappers accept the corresponding React.ComponentProps, while Radix wrappers use React.ComponentProps<typeof Primitive> so native props, Radix behavior, and refs remain available to consumers.

Components with visual variants define them with cva. Dimensional size axes use sm, md, and lg, with md as the omitted default; purpose-specific sizes such as icon or compact are valid when they describe different geometry. Components that support changing their root element expose the Radix Slot asChild pattern:

tsx
<Button asChild>
  <Link to="/dashboard">Open dashboard</Link>
</Button>

Root elements use data-slot, and variant-aware controls may expose data-variant and data-size. These attributes support shared styling and consumer slot targeting without adding app-specific props to the package.

Adding New Components

To add new shadcn/ui components:

bash
# From an app directory (webapp or marketing)
cd apps/webapp
pnpm dlx shadcn@canary add [COMPONENT]

The app's components.json points shadcn at packages/ui/src/styles/index.css and aliases generated components into packages/ui. The package's wildcard component export makes a new component available at @jubiloop/ui/components/{component-name} without another export entry.

Styling

Shared style ownership is divided by responsibility:

PathResponsibility
src/styles/primitives.cssPrivate raw color, typography, spacing, shape, shadow, and motion
src/styles/tokens.cssPublic semantic roles and light/dark values
src/styles/theme.cssExact Tailwind mappings and the shared dark variant
src/styles/base.cssShared element and document rules
src/styles/utilities.cssComplete type-* role utilities, confettiFall keyframes, and reduced-motion rules
src/styles/index.cssStyle composition and packages/ui/src source scan
@jubiloop/ui/styles.cssPublic stylesheet export
@jubiloop/ui/postcss.configPublic PostCSS configuration export

Each application owns a small CSS wrapper:

  • apps/webapp/src/globals.css
  • apps/marketing/src/app/globals.css

The wrapper imports @jubiloop/ui/styles.css and defines the Tailwind @source scan for that application's src directory. Don't add application paths to packages/ui/src/styles/index.css.

Tailwind runs through the shared PostCSS configuration. Consumers use the supported @jubiloop/ui/styles.css and @jubiloop/ui/postcss.config entry points rather than adding @tailwindcss/vite or another app-specific Tailwind processor.

Token Layers

primitives.css is the only raw-value layer. Every declaration uses the private --jui-ref-* prefix. These values can contain hex, HSL, lengths, shadow recipes, and motion values. Only primitives.css and tokens.css may reference private names. Components, applications, theme.css, base.css, and utilities.css must not use them, and theme.css must not expose them as Tailwind utilities.

tokens.css defines the public semantic contract. Semantic declarations point to a private primitive or to an intentional semantic alias, such as --border: var(--border-default). Raw values don't belong in this file.

The public tokens have two scopes:

  • Theme-sensitive tokens exist in both :root and .dark. This includes surfaces, content, brand and status colors, boundaries, focus, navigation, and elevation.
  • Root-only tokens don't change by color mode. This includes typography, page spacing, content widths, shape and role radii, and motion. Under prefers-reduced-motion: reduce, tokens.css shortens deliberate and decorative motion, while utilities.css disables smooth scrolling and shortens transition and animation durations.

Use the named radius and elevation roles; the package does not provide generic --radius or --shadow compatibility tokens.

theme.css maps semantic tokens to Tailwind namespaces. Application and component code should use the resulting semantic classes:

tsx
<section className="bg-background text-foreground">
  <article className="rounded-card border border-border bg-card text-card-foreground shadow-raised">
    <button className="bg-primary text-primary-foreground hover:bg-primary/90">Continue</button>
  </article>
</section>

Other mapped roles include bg-surface-low, status colors such as text-success, layout classes such as px-page-gutter and gap-layout-gap, control geometry such as h-control-md and min-h-control-multiline, content widths such as max-w-content and max-w-page, role radii, elevation shadows, and named motion utilities such as duration-feedback and ease-standard. Named duration utilities come from Tailwind's --transition-duration-* namespace while retaining the public --motion-duration-* semantic source tokens. Prefer semantic color utilities when a role fits the design. This contract doesn't claim that every raw Tailwind palette utility is forbidden.

RoleSemantic utility examples
Surfaces and contentbg-background, bg-card, bg-surface-low, text-foreground, text-muted-foreground
Statusesbg-success, text-warning, bg-info-subtle, text-destructive-subtle-foreground
Boundaries and focusborder-border, border-input, border-focus-ring, ring-3, ring-focus-ring/30
Layoutpx-page-gutter, gap-layout-gap, h-control-md, min-h-control-multiline, max-w-content, max-w-page
Shape and elevationrounded-control, rounded-card, rounded-panel, rounded-pill, shadow-raised, shadow-overlay
Motionduration-feedback, duration-transition, ease-standard, translate-y-lift-subtle, scale-subtle

utilities.css defines the eight semantic type-* utilities described below and keeps the confettiFall keyframes inside @layer utilities. Other token-backed utilities come from the mappings in theme.css.

Class Merging

Shared components pass their defaults and consumer className values through cn() from src/lib/utils.ts. Its Tailwind Merge configuration mirrors custom theme namespaces that are not recognized by default, including semantic text sizes, spacing, containers, radii, shadows, easing, durations, translation, and scale. This lets a consumer override h-control-md with h-10 or rounded-card with rounded-lg without retaining two conflicting declarations.

The compound type-* roles use asymmetric conflicts. A later type-body replaces earlier family, size, weight, leading, and tracking classes. A later text-sm or font-bold remains as an intentional local exception to one part of the role. Color classes remain independent.

Typography

Typography is part of the shared token contract. primitives.css defines private raw values for three families and each role's size, line-height, weight, and letter spacing. tokens.css exposes the public display, heading-lg, heading-md, heading-sm, body, label, caption, and code roles.

RoleFamilySize behaviorUtility
displayNewsreader Variableclamp(3rem, calc(2.5rem + 2vw), 3.75rem)type-display
heading-lgNewsreader Variableclamp(2rem, calc(1.5rem + 1.5vw), 3rem)type-heading-lg
heading-mdNewsreader Variableclamp(1.5rem, calc(1.25rem + 1vw), 2.25rem)type-heading-md
heading-smNewsreader Variableclamp(1.25rem, calc(1.1rem + 0.5vw), 1.5rem)type-heading-sm
bodyManrope Variable1remtype-body
labelManrope Variable0.875remtype-label
captionManrope Variable0.75remtype-caption
codeSystem monospace0.875remtype-code

Manrope Variable is the default font-sans family for body text, UI, labels, and captions. Newsreader Variable is available only through explicit display or heading roles. Code stays on the system monospace stack; Fontsource doesn't supply it.

theme.css maps font-sans, font-display, font-heading, and font-code, plus text-display, text-heading-lg, text-heading-md, text-heading-sm, text-body, text-label, text-caption, and text-code with their line heights. utilities.css combines each public role's family, size, line-height, weight, and tracking into a single class:

tsx
<header>
  <h1 className="type-display text-foreground">Plan something memorable</h1>
  <p className="type-body text-muted-foreground">Keep every detail in one place.</p>
</header>

<label className="type-label" htmlFor="event-date">
  Event date
</label>

<code className="type-code">pnpm run dev</code>

Use a type-* utility when the complete semantic role should control all five properties. Use font-display or font-heading only when applying the Newsreader family to an existing page-level hierarchy whose size, line-height, weight, and tracking should remain unchanged.

Don't stack superseded text-size, line-height, weight, family, or tracking utilities on a type-* class. For example, type-heading-lg text-xl leading-tight font-bold replaces parts of the semantic role and obscures which contract applies. Add one of those utilities only for an intentional local exception.

Display and heading sizes use clamp() so they scale with the viewport and remain bounded. Pages using these roles must reflow without horizontal text overflow at 320px. The browser typography spec also checks a 640px viewport as a proxy for a 1280px layout at 200% zoom; it doesn't set real browser zoom. Wrapping is expected. Don't force single-line headings or fixed-width containers that break that reflow.

Newsreader includes a variable italic face. Use the normal Tailwind italic utility with type-display, type-heading-lg, type-heading-md, or type-heading-sm where italics fit the editorial treatment:

tsx
<h2 className="type-heading-lg italic">A gathering worth remembering</h2>

Font Delivery

The workspace catalog pins @fontsource-variable/manrope and @fontsource-variable/newsreader. Both production app manifests depend on those packages through catalog:. Each production entrypoint imports the required Fontsource styles directly:

ts
import '@fontsource-variable/manrope/wght.css'
import '@fontsource-variable/newsreader/wght-italic.css'
import '@fontsource-variable/newsreader/wght.css'

apps/webapp/src/main.tsx and both marketing route-group layouts own these imports while @jubiloop/ui owns the shared families, role tokens, Tailwind mappings, and utilities. Fontsource bundles local, unicode-ranged WOFF2 assets and uses font-display: swap. Production pages don't make Google Fonts runtime calls or load font files from remote URLs. There are no manual preloads or custom PostCSS font transformations. Font licenses are recorded once in THIRD_PARTY_NOTICES.md.

The typography Playwright project checks both production apps for available self-hosted font faces, font requests limited to the configured local app origins, and horizontal reflow when WOFF2 requests are blocked. It doesn't assert a specific computed fallback family. Run it from playwright-framework/ with pnpm exec playwright test --project=typography.

Light And Dark Modes

:root supplies the light values. An application activates dark mode by adding .dark to an ancestor, normally the document root. Applying that class changes the theme-sensitive semantic tokens, and the shared dark variant in theme.css targets its descendants. The webapp configures next-themes in src/main.tsx to control this class. Marketing explicitly applies the light class and color scheme, so it uses only the light values even though it imports both token scopes.

The following 19 text token pairs are designed to meet at least 4.5:1 in light and dark mode:

text
foreground/background                  card-foreground/card
popover-foreground/popover              primary-foreground/primary
secondary-foreground/secondary          tertiary-foreground/tertiary
muted-foreground/muted                  accent-foreground/accent
success-foreground/success              success-subtle-foreground/success-subtle
warning-foreground/warning              warning-subtle-foreground/warning-subtle
info-foreground/info                    info-subtle-foreground/info-subtle
destructive-foreground/destructive      destructive-subtle-foreground/destructive-subtle
sidebar-foreground/sidebar-background   sidebar-primary-foreground/sidebar-primary
sidebar-accent-foreground/sidebar-accent

The following four non-text token pairs are designed to meet at least 3:1 in both modes: input/background, border-strong/background, focus-ring/background, and sidebar-ring/sidebar-background.

Verify contrast in rendered browser states. Opacity, gradients, images, overlays, state changes, arbitrary utility combinations, and a token used on a different surface can change effective contrast even when the base token pair meets its target.

Changing Tokens

Token changes can affect every package consumer in both themes. Search all consumers and test the rendered states before changing an existing role.

  1. Add or change the raw value in primitives.css using a --jui-ref-* name.
  2. Reference that primitive from a role in tokens.css. Add both light and dark declarations for a theme-sensitive role; add root-only values only to :root.
  3. If consumers need a Tailwind utility, add the semantic mapping to theme.css. Never map a private primitive directly.
  4. If the mapping adds a custom Tailwind value, update src/lib/utils.ts when Tailwind Merge does not recognize that namespace value by default, and add coverage to scripts/class-merge.test.mjs.
  5. Use the supported semantic classes at the consumer and keep the public contract focused on current roles.
  6. Run the UI tests and both consuming app builds, then verify affected rendered states.

The style-token contract owns typography primitives, semantic roles, Tailwind mappings, and type-* utilities. base.css applies the body role values and the system monospace family to code elements. Component variants remain with their component implementation rather than in token or utility stylesheets.

Validation

Run from the repository root:

bash
pnpm --filter @jubiloop/ui lint
pnpm --filter @jubiloop/ui test
pnpm --filter @jubiloop/ui check-types

test compiles the public stylesheet through the real Tailwind/PostCSS pipeline and confirms that representative color, border, radius, shadow, motion, and typography utilities are generated. It also verifies that cn() resolves custom semantic utilities against standard Tailwind overrides. These are contract and smoke tests, not complete design-system or accessibility tests.

Run pnpm run build:webapp and pnpm run build:marketing after shared style changes to verify both consumer pipelines. Use browser, visual, and accessibility coverage for rendered behavior. The UI package does not maintain a separate custom token or source-policy validator.

Component Ownership

Add a component here when it is reusable, presentational, and domain-agnostic. Radix wrappers, inputs, buttons, cards, overlays, navigation primitives, logo and social icon wrappers, and layout primitives fit this package.

Keep API calls, route behavior, authentication, event and organization logic, app state, and route-specific compositions in the consuming application. A shared component can expose state and event props, but it must not choose an application's state owner or API client.

Built with ❤️ by the Jubiloop team