forked from defunkt/forge
1068 lines
22 KiB
Markdown
1068 lines
22 KiB
Markdown
# Forge Guide
|
|
|
|
A complete guide to writing components with Forge.
|
|
|
|
## Table of Contents
|
|
|
|
- [Getting Started](#getting-started)
|
|
- [Basic Components](#basic-components)
|
|
- [HTML Tags](#html-tags)
|
|
- [CSS Properties](#css-properties)
|
|
- [States](#states)
|
|
- [Variants](#variants)
|
|
- [Parts](#parts)
|
|
- [Custom Render](#custom-render)
|
|
- [Selectors](#selectors)
|
|
- [Scopes](#scopes)
|
|
- [Themes](#themes)
|
|
- [SSR and SPA](#ssr-and-spa)
|
|
|
|
---
|
|
|
|
## Getting Started
|
|
|
|
Import `define` and start creating components. Each call to `define` generates real CSS classes and returns a JSX component.
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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.
|
|
|
|
```tsx
|
|
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.
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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.
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```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`:
|
|
|
|
```tsx
|
|
{ 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:
|
|
|
|
```tsx
|
|
{ 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:
|
|
|
|
```tsx
|
|
{ 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`.
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
states: {
|
|
hover: { background: 'darkblue' }, // same as ':hover'
|
|
}
|
|
```
|
|
|
|
### Complex pseudo-selectors
|
|
|
|
Use the full string for compound selectors:
|
|
|
|
```tsx
|
|
states: {
|
|
':not(:disabled):hover': { background: 'darkblue' },
|
|
':not(:disabled):active': { transform: 'translateY(1px)' },
|
|
}
|
|
```
|
|
|
|
Generated CSS:
|
|
|
|
```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`.
|
|
|
|
```tsx
|
|
const Button = define('Button', {
|
|
base: 'button',
|
|
padding: 20,
|
|
background: 'blue',
|
|
|
|
variants: {
|
|
disabled: {
|
|
opacity: 0.3,
|
|
cursor: 'not-allowed',
|
|
},
|
|
rounded: {
|
|
borderRadius: 999,
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<Button>Normal</Button>
|
|
<Button disabled>Disabled</Button>
|
|
<Button rounded>Pill</Button>
|
|
<Button disabled rounded>Both</Button>
|
|
```
|
|
|
|
Generated CSS:
|
|
|
|
```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>`:
|
|
|
|
```html
|
|
<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.
|
|
|
|
```tsx
|
|
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 },
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<Button intent="primary">Save</Button>
|
|
<Button intent="danger" size="large">Delete Account</Button>
|
|
<Button intent="ghost" size="small">Cancel</Button>
|
|
```
|
|
|
|
Generated CSS:
|
|
|
|
```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:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
<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.
|
|
|
|
```tsx
|
|
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>
|
|
)
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<Card title="Welcome" footer="Last updated today">
|
|
This is the card body content.
|
|
</Card>
|
|
```
|
|
|
|
Generated CSS classes:
|
|
|
|
```css
|
|
.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:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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>
|
|
)
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<UserProfile size="compact" name="Alex" ... />
|
|
<UserProfile size="large" verified name="Jordan" ... />
|
|
```
|
|
|
|
Generated CSS:
|
|
|
|
```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:
|
|
|
|
```tsx
|
|
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).
|
|
|
|
```tsx
|
|
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>
|
|
)
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<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:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
const NavLink = define('NavLink', {
|
|
base: 'a',
|
|
color: '#888',
|
|
textDecoration: 'none',
|
|
|
|
selectors: {
|
|
'&:hover': { color: '#fff' },
|
|
'&[aria-current]': { color: '#fff', textDecoration: 'underline' },
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<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.
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```css
|
|
.Checkbox_Input:checked + .Checkbox_Label {
|
|
color: green;
|
|
font-weight: bold;
|
|
}
|
|
```
|
|
|
|
### CSS-only tab switcher
|
|
|
|
Hidden radio inputs + sibling selectors = tabs without JavaScript:
|
|
|
|
```tsx
|
|
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>
|
|
)
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<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:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
// 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
|
|
```
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```tsx
|
|
// 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)`:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
// Set initial theme
|
|
<body data-theme="dark">
|
|
|
|
// Switch at runtime
|
|
document.body.setAttribute('data-theme', 'light')
|
|
```
|
|
|
|
Generated CSS:
|
|
|
|
```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
|
|
|
|
```tsx
|
|
// 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:
|
|
|
|
```tsx
|
|
import { define, Styles } from 'forge'
|
|
|
|
const Page = () => (
|
|
<html>
|
|
<head>
|
|
<Styles />
|
|
</head>
|
|
<body>...</body>
|
|
</html>
|
|
)
|
|
```
|
|
|
|
Or write CSS to a file using `stylesToCSS()`:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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',
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<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>
|
|
```
|