forge/docs/GUIDE.md
2026-02-18 20:48:46 -08:00

22 KiB

Forge Guide

A complete guide to writing components with Forge.

Table of Contents


Getting Started

Import define and start creating components. Each call to define generates real CSS classes and returns a JSX component.

import { define } from 'forge'

const Box = define('Box', {
  padding: 20,
  background: '#111',
})

// Renders: <div class="Box">...</div>
<Box>Hello</Box>

Include <Styles /> in your HTML head to render the generated CSS:

import { define, Styles } from 'forge'
// or use define.Styles — same thing

<html>
  <head>
    <Styles />
  </head>
  <body>...</body>
</html>

Basic Components

Named components

Pass a name as the first argument. The name becomes the CSS class.

const Card = define('Card', {
  padding: 20,
  background: '#111',
  borderRadius: 8,
})

// <div class="Card">...</div>

Names must be unique — defining the same name twice throws an error.

Anonymous components

Omit the name and Forge generates one from the base tag: Div, Button2, Anchor3, etc.

const Box = define({ display: 'flex', gap: 16 })
// class="Div"

const Link = define({ base: 'a', color: 'blue' })
// class="Anchor"

HTML Tags

By default, components render as <div>. Use base to change the tag:

const Button = define('Button', {
  base: 'button',
  padding: 20,
  cursor: 'pointer',
})
// <button class="Button">...</button>

const Heading = define('Heading', {
  base: 'h1',
  fontSize: 28,
})
// <h1 class="Heading">...</h1>

Attribute shorthand

Set default attributes right in the base string:

const Radio = define('Radio', {
  base: 'input[type=radio]',
})
// <input type="radio" class="Radio" />

const Checkbox = define('Checkbox', {
  base: 'input[type=checkbox]',
})
// <input type="checkbox" class="Checkbox" />

Props passed at usage time override base attributes.


CSS Properties

Write CSS properties in camelCase. They compile to real CSS at definition time.

const Card = define('Card', {
  display: 'flex',
  flexDirection: 'column',
  gap: 16,
  padding: 20,
  backgroundColor: '#111',
  borderRadius: 8,
  boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
})

Generated CSS:

.Card {
  background-color: #111;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 20px;
}

Numeric values

Numbers are auto-converted to px:

{ padding: 20 }     // padding: 20px
{ margin: 0 }       // margin: 0px
{ fontSize: 14 }    // font-size: 14px
{ borderRadius: 8 } // border-radius: 8px

Except for unitless properties, which stay as plain numbers:

{ opacity: 0.5 }    // opacity: 0.5
{ zIndex: 10 }      // z-index: 10
{ flex: 1 }         // flex: 1
{ flexGrow: 2 }     // flex-grow: 2
{ flexShrink: 0 }   // flex-shrink: 0
{ fontWeight: 400 }  // font-weight: 400
{ lineHeight: 1.6 } // line-height: 1.6
{ order: 3 }        // order: 3

Strings are passed through as-is:

{ padding: '12px 24px' }    // padding: 12px 24px
{ border: '1px solid #222' } // border: 1px solid #222
{ margin: '0 auto' }        // margin: 0 auto

All supported properties

Every standard CSS property is supported in camelCase form: layout (display, position, flexDirection, gridTemplateColumns, etc.), box model (margin, padding, width, height, etc.), typography (fontSize, fontWeight, lineHeight, letterSpacing, etc.), visual (background, border, boxShadow, opacity, etc.), animation (transition, animation, transform, etc.), and SVG (fill, stroke, strokeWidth, etc.).


States

Pseudo-selectors like :hover, :focus, :active, and :disabled.

const Button = define('Button', {
  base: 'button',
  padding: 20,
  background: 'blue',
  cursor: 'pointer',

  states: {
    ':hover': { background: 'darkblue' },
    ':active': { transform: 'translateY(1px)' },
    ':focus': { outline: '2px solid white' },
    ':disabled': { opacity: 0.3, cursor: 'not-allowed' },
  },
})

The colon is optional — hover and :hover are equivalent:

states: {
  hover: { background: 'darkblue' },  // same as ':hover'
}

Complex pseudo-selectors

Use the full string for compound selectors:

states: {
  ':not(:disabled):hover': { background: 'darkblue' },
  ':not(:disabled):active': { transform: 'translateY(1px)' },
}

Generated CSS:

.Button:not(:disabled):hover { background: darkblue; }
.Button:not(:disabled):active { transform: translateY(1px); }

Variants

Variants are typed props that apply conditional CSS classes. They replace inline styles with a clean, declarative API.

Boolean variants

A variant whose value is a style object. Activated by passing true.

const Button = define('Button', {
  base: 'button',
  padding: 20,
  background: 'blue',

  variants: {
    disabled: {
      opacity: 0.3,
      cursor: 'not-allowed',
    },
    rounded: {
      borderRadius: 999,
    },
  },
})
<Button>Normal</Button>
<Button disabled>Disabled</Button>
<Button rounded>Pill</Button>
<Button disabled rounded>Both</Button>

Generated CSS:

.Button { padding: 20px; background: blue; }
.Button.disabled { cursor: not-allowed; opacity: 0.3; }
.Button.rounded { border-radius: 999px; }

HTML output for <Button disabled rounded>:

<button class="Button disabled rounded">Both</button>

Variant props are consumed by Forge and not passed to the HTML element.

Keyed variants

A variant whose value is an object of named options. Activated by passing a string.

const Button = define('Button', {
  base: 'button',
  padding: 16,

  variants: {
    intent: {
      primary: { background: 'blue', color: 'white' },
      secondary: { background: '#333', color: '#ccc' },
      danger: { background: 'red', color: 'white' },
      ghost: { background: 'transparent', color: 'gray' },
    },
    size: {
      small: { padding: '8px 16px', fontSize: 12 },
      large: { padding: '16px 32px', fontSize: 16 },
    },
  },
})
<Button intent="primary">Save</Button>
<Button intent="danger" size="large">Delete Account</Button>
<Button intent="ghost" size="small">Cancel</Button>

Generated CSS:

.Button.intent-primary { background: blue; color: white; }
.Button.intent-danger { background: red; color: white; }
.Button.size-small { font-size: 12px; padding: 8px 16px; }
.Button.size-large { font-size: 16px; padding: 16px 32px; }

Variants with states

Variants can include their own pseudo-selectors:

variants: {
  intent: {
    danger: {
      background: 'red',
      states: {
        ':not(:disabled):hover': { background: '#cc0000' },
      },
    },
    secondary: {
      background: '#333',
      states: {
        ':not(:disabled):hover': { borderColor: 'green' },
      },
    },
  },
}

Combining multiple variants

Multiple keyed and boolean variants can be used together freely:

<Button intent="primary" size="small">Small Primary</Button>
<Button intent="danger" size="large" disabled>Large Danger Disabled</Button>

Parts

Parts are named sub-components within a component. They get their own CSS classes and can have their own base tags, states, and selectors.

const Card = define('Card', {
  padding: 20,
  background: '#111',

  parts: {
    Header: {
      base: 'h2',
      fontSize: 24,
      marginBottom: 12,
    },
    Body: {
      fontSize: 14,
      lineHeight: 1.6,
      color: '#888',
    },
    Footer: {
      base: 'footer',
      marginTop: 16,
      paddingTop: 12,
      borderTop: '1px solid #333',
      fontSize: 12,
    },
  },

  render({ props, parts: { Root, Header, Body, Footer } }) {
    return (
      <Root>
        <Header>{props.title}</Header>
        <Body>{props.children}</Body>
        <Footer>{props.footer}</Footer>
      </Root>
    )
  },
})
<Card title="Welcome" footer="Last updated today">
  This is the card body content.
</Card>

Generated CSS classes:

.Card { padding: 20px; background: #111; }
.Card_Header { font-size: 24px; margin-bottom: 12px; }
.Card_Body { color: #888; font-size: 14px; line-height: 1.6; }
.Card_Footer { border-top: 1px solid #333; font-size: 12px; margin-top: 16px; padding-top: 12px; }

Parts with states

Parts can have their own pseudo-selectors:

parts: {
  Tab: {
    base: 'button',
    color: '#888',
    borderBottom: '1px solid transparent',

    states: {
      ':hover': { color: '#fff' },
    },
  },
}

Variants that affect parts

Variants can override styles on specific parts:

const UserProfile = define('UserProfile', {
  padding: 24,

  parts: {
    Avatar: { base: 'img', width: 64, height: 64, borderRadius: '50%' },
    Name: { fontSize: 18 },
    Bio: { fontSize: 14, color: '#888' },
  },

  variants: {
    size: {
      compact: {
        padding: 16,              // override root
        parts: {
          Avatar: { width: 48, height: 48 },  // override part
          Name: { fontSize: 16 },
        },
      },
      large: {
        padding: 32,
        parts: {
          Avatar: { width: 96, height: 96 },
          Name: { fontSize: 24 },
        },
      },
    },
    verified: {
      parts: {
        Avatar: { border: '2px solid gold' },
      },
    },
  },

  render({ props, parts: { Root, Avatar, Name, Bio } }) {
    return (
      <Root>
        <Avatar src={props.avatarUrl} alt={props.name} />
        <Name>{props.name}{props.verified && ' ✓'}</Name>
        <Bio>{props.bio}</Bio>
      </Root>
    )
  },
})
<UserProfile size="compact" name="Alex" ... />
<UserProfile size="large" verified name="Jordan" ... />

Generated CSS:

.UserProfile { padding: 24px; }
.UserProfile_Avatar { border-radius: 50%; height: 64px; width: 64px; }
.UserProfile.size-compact { padding: 16px; }
.UserProfile_Avatar.size-compact { height: 48px; width: 48px; }
.UserProfile_Avatar.verified { border: 2px solid gold; }

Applying variants to parts in render

Inside render(), you can pass variant props directly to part components:

const Tabs = define('Tabs', {
  display: 'flex',

  parts: {
    Tab: {
      base: 'button',
      color: '#888',
      borderBottom: '1px solid transparent',
    },
  },

  variants: {
    active: {
      parts: {
        Tab: {
          color: 'green',
          borderBottom: '1px solid green',
        },
      },
    },
  },

  render({ props, parts: { Root, Tab } }) {
    return (
      <Root>
        {props.items?.map((item: any) => (
          <Tab active={item.active}>{item.label}</Tab>
        ))}
      </Root>
    )
  },
})

The active prop on <Tab> adds the variant class to that specific tab instance. It is not passed through to the HTML.


Custom Render

Override the default rendering with a render function. It receives props (everything passed to the component) and parts (component functions for Root and all named parts).

const FormGroup = define('FormGroup', {
  marginBottom: 24,

  parts: {
    Label: { base: 'label', display: 'block', fontSize: 14, marginBottom: 8 },
    Helper: { fontSize: 12, color: '#888', marginTop: 6 },
    Error: { fontSize: 12, color: 'red', marginTop: 6 },
  },

  render({ props, parts: { Root, Label, Helper, Error } }) {
    return (
      <Root>
        {props.label && <Label>{props.label}</Label>}
        {props.children}
        {props.helper && <Helper>{props.helper}</Helper>}
        {props.error && <Error>{props.error}</Error>}
      </Root>
    )
  },
})
<FormGroup label="Email" helper="We'll never share your email">
  <Input type="email" placeholder="you@example.com" />
</FormGroup>

<FormGroup label="Username" error="Username is already taken">
  <Input status="error" value="admin" />
</FormGroup>

Destructuring props

Destructure to separate custom props from HTML passthrough props:

render({ props: { title, subtitle, ...rest }, parts: { Root, H2, P } }) {
  return (
    <Root {...rest}>
      <H2>{title}</H2>
      <P>{subtitle}</P>
    </Root>
  )
}

Without render

If no render is provided, the component renders its children into the root tag:

const Box = define('Box', { padding: 20 })

// Equivalent to:
// render({ props, parts: { Root } }) {
//   return <Root {...props}>{props.children}</Root>
// }

Selectors

The selectors key lets you write custom CSS selectors. Use & for the current element and @PartName to reference other parts.

Basic selectors

const NavLink = define('NavLink', {
  base: 'a',
  color: '#888',
  textDecoration: 'none',

  selectors: {
    '&:hover': { color: '#fff' },
    '&[aria-current]': { color: '#fff', textDecoration: 'underline' },
  },
})
<NavLink href="/home" aria-current="page">Home</NavLink>
<NavLink href="/about">About</NavLink>

Cross-part selectors

Reference other parts with @PartName. This is the mechanism that enables CSS-only interactive components.

const Checkbox = define('Checkbox', {
  parts: {
    Input: {
      base: 'input[type=checkbox]',
      display: 'none',
    },
    Label: {
      base: 'label',
      padding: 10,
      cursor: 'pointer',
      color: 'gray',

      selectors: {
        // When the Input is checked, style this Label
        '@Input:checked + &': {
          color: 'green',
          fontWeight: 'bold',
        },
        // When the Input is disabled, style this Label
        '@Input:disabled + &': {
          opacity: 0.5,
          cursor: 'not-allowed',
        },
      },
    },
  },

  render({ props, parts: { Root, Input, Label } }) {
    return (
      <Root>
        <Input id={props.id} checked={props.checked} disabled={props.disabled} />
        <Label for={props.id}>{props.label}</Label>
      </Root>
    )
  },
})

The @Input in @Input:checked + & is replaced with .Checkbox_Input, and & is replaced with .Checkbox_Label, producing:

.Checkbox_Input:checked + .Checkbox_Label {
  color: green;
  font-weight: bold;
}

CSS-only tab switcher

Hidden radio inputs + sibling selectors = tabs without JavaScript:

const TabSwitcher = define('TabSwitcher', {
  parts: {
    Input: {
      base: 'input[type=radio]',
      display: 'none',
    },
    TabBar: {
      display: 'flex',
      borderBottom: '1px solid #333',
    },
    TabLabel: {
      base: 'label',
      padding: '12px 24px',
      color: '#888',
      cursor: 'pointer',

      states: {
        ':hover': { color: '#fff' },
      },

      selectors: {
        '@Input:checked + &': {
          color: 'green',
          borderBottom: '1px solid green',
        },
      },
    },
    Content: {
      display: 'none',
      padding: 24,

      selectors: {
        // General sibling combinator: when a radio is checked,
        // show the corresponding content panel
        '@Input:checked ~ &': { display: 'block' },
      },
    },
  },

  render({ props, parts: { Root, Input, TabBar, TabLabel, Content } }) {
    return (
      <Root>
        <TabBar>
          {props.tabs?.map((tab: any, i: number) => (
            <>
              <Input id={`${props.name}-${tab.id}`} name={props.name} checked={i === 0} />
              <TabLabel for={`${props.name}-${tab.id}`}>{tab.label}</TabLabel>
            </>
          ))}
        </TabBar>
        {props.tabs?.map((tab: any) => (
          <Content>{tab.content}</Content>
        ))}
      </Root>
    )
  },
})
<TabSwitcher
  name="demo"
  tabs={[
    { id: 'one', label: 'Tab 1', content: <p>First panel</p> },
    { id: 'two', label: 'Tab 2', content: <p>Second panel</p> },
    { id: 'three', label: 'Tab 3', content: <p>Third panel</p> },
  ]}
/>

Selector reference

Selector Meaning
&:hover This element on hover
&[aria-current] This element when attribute is present
@Input:checked + & This element when adjacent Input is checked
@Input:checked ~ & This element when any preceding Input sibling is checked
@Input:disabled + & This element when adjacent Input is disabled
@Input:checked + &:hover This element on hover, but only when Input is checked

Scopes

When building a family of related components, createScope prefixes all names automatically:

import { createScope } from 'forge'

const { define } = createScope('Button')

const Button = define('Root', {     // CSS class: "Button" (Root is special)
  base: 'button',
  padding: 20,
})

const ButtonRow = define('Row', {   // CSS class: "ButtonRow"
  display: 'flex',
  gap: 16,
})

const ButtonIcon = define('Icon', { // CSS class: "ButtonIcon"
  width: 20,
  height: 20,
})

Root is the special case — define('Root', ...) with scope 'Button' produces class Button, not ButtonRoot.


Themes

Built-in CSS custom properties with type safety.

Define themes

Create a theme file with your tokens:

// darkTheme.tsx
export default {
  'colors-bg': '#0a0a0a',
  'colors-bgElevated': '#111',
  'colors-fg': '#00ff00',
  'colors-fgMuted': '#888',
  'colors-border': '#222',
  'colors-accent': '#00ff00',
  'colors-accentDim': '#008800',

  'fonts-mono': "'Monaco', 'Menlo', monospace",

  'spacing-sm': '12px',
  'spacing-md': '16px',
  'spacing-lg': '24px',
  'spacing-xl': '32px',

  'radius-sm': '4px',
  'radius-md': '8px',
} as const
// lightTheme.tsx
export default {
  'colors-bg': '#f5f5f0',
  'colors-bgElevated': '#fff',
  'colors-fg': '#0a0a0a',
  'colors-fgMuted': '#666',
  'colors-border': '#ddd',
  'colors-accent': '#0066cc',
  'colors-accentDim': '#004499',

  'fonts-mono': "'Monaco', 'Menlo', monospace",

  'spacing-sm': '12px',
  'spacing-md': '16px',
  'spacing-lg': '24px',
  'spacing-xl': '32px',

  'radius-sm': '4px',
  'radius-md': '8px',
} as const

Register themes

// themes.tsx
import { createThemes } from 'forge'
import darkTheme from './darkTheme'
import lightTheme from './lightTheme'

export const theme = createThemes({
  dark: darkTheme,
  light: lightTheme,
})

createThemes returns a typed function. It only accepts keys that exist in your theme objects.

Use theme values

theme('key') returns var(--theme-key):

import { theme } from './themes'

const Card = define('Card', {
  padding: theme('spacing-lg'),        // var(--theme-spacing-lg)
  background: theme('colors-bgElevated'), // var(--theme-colors-bgElevated)
  color: theme('colors-fg'),           // var(--theme-colors-fg)
  border: `1px solid ${theme('colors-border')}`,
  borderRadius: theme('radius-md'),
})

Switch themes

Themes are controlled by the data-theme attribute:

// Set initial theme
<body data-theme="dark">

// Switch at runtime
document.body.setAttribute('data-theme', 'light')

Generated CSS:

[data-theme="dark"] {
  --theme-colors-bg: #0a0a0a;
  --theme-colors-fg: #00ff00;
  /* ... */
}
[data-theme="light"] {
  --theme-colors-bg: #f5f5f0;
  --theme-colors-fg: #0a0a0a;
  /* ... */
}

Other theme APIs

// Register a single theme
import { createTheme } from 'forge'
createTheme('dark', { bgColor: '#000', fgColor: '#fff' })

// Extend existing themes with new tokens
import { extendThemes } from 'forge'
extendThemes({ dark: { 'colors-success': '#0f0' } })

// Untyped fallback for dynamic theme keys
import { themeVar } from 'forge'
themeVar('colors-bg')  // 'var(--theme-colors-bg)'

SSR and SPA

Server-side rendering

Include <Styles /> in the <head> to inline all CSS:

import { define, Styles } from 'forge'

const Page = () => (
  <html>
    <head>
      <Styles />
    </head>
    <body>...</body>
  </html>
)

Or write CSS to a file using stylesToCSS():

import { stylesToCSS } from 'forge'

// Write to a .css file and serve it
const css = stylesToCSS()
Bun.write('public/main.css', css)

// Then link it
<link rel="stylesheet" href="/main.css" />

Single-page apps

In browser environments, Forge automatically injects a <style id="forge-styles"> tag into document.head after every define() call. No manual setup needed.

HMR

Forge automatically clears and re-registers styles when modules are hot-reloaded via import.meta.hot.dispose.


Full Example

Putting it all together — a button component with scoping, themes, multiple variant types, states, and combined usage:

import { createScope } from 'forge'
import { theme } from './themes'

const { define } = createScope('Button')

const Button = define('Root', {
  base: 'button',

  padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
  display: 'inline-flex',
  alignItems: 'center',
  justifyContent: 'center',
  gap: theme('spacing-xs'),
  background: theme('colors-accent'),
  color: theme('colors-bg'),
  border: `1px solid ${theme('colors-accent')}`,
  borderRadius: theme('radius-sm'),
  fontSize: 14,
  cursor: 'pointer',
  transition: 'all 0.2s ease',

  states: {
    ':not(:disabled):hover': {
      background: theme('colors-accentDim'),
    },
    ':not(:disabled):active': {
      transform: 'translateY(1px)',
    },
  },

  variants: {
    intent: {
      primary: {
        background: theme('colors-accent'),
        color: theme('colors-bg'),
      },
      secondary: {
        background: theme('colors-bgElevated'),
        color: theme('colors-fg'),
        border: `1px solid ${theme('colors-border')}`,
        states: {
          ':not(:disabled):hover': {
            borderColor: theme('colors-borderActive'),
          },
        },
      },
      danger: {
        background: '#ff0000',
        states: {
          ':not(:disabled):hover': { background: '#cc0000' },
        },
      },
      ghost: {
        background: 'transparent',
        color: theme('colors-fgMuted'),
      },
    },
    size: {
      small: { padding: '8px 16px', fontSize: 12 },
      large: { padding: '16px 32px', fontSize: 16 },
    },
    disabled: {
      opacity: 0.3,
      cursor: 'not-allowed',
    },
  },
})

const ButtonRow = define('Row', {
  display: 'flex',
  gap: theme('spacing-md'),
  flexWrap: 'wrap',
  alignItems: 'center',
})
<ButtonRow>
  <Button intent="primary">Save</Button>
  <Button intent="secondary">Cancel</Button>
  <Button intent="danger" size="large">Delete</Button>
  <Button intent="ghost" size="small">Skip</Button>
  <Button disabled>Disabled</Button>
</ButtonRow>