22 KiB
Forge Guide
A complete guide to writing components with Forge.
Table of Contents
- Getting Started
- Basic Components
- HTML Tags
- CSS Properties
- States
- Variants
- Parts
- Custom Render
- Selectors
- Scopes
- Themes
- SSR and SPA
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>