From 8389a41c599d74fe68b6c05c05aca0195f0a07ac Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 18 Feb 2026 20:48:46 -0800 Subject: [PATCH] guide --- docs/GUIDE.md | 1067 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1067 insertions(+) create mode 100644 docs/GUIDE.md diff --git a/docs/GUIDE.md b/docs/GUIDE.md new file mode 100644 index 0000000..be6fc79 --- /dev/null +++ b/docs/GUIDE.md @@ -0,0 +1,1067 @@ +# 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:
...
+Hello +``` + +Include `` in your HTML head to render the generated CSS: + +```tsx +import { define, Styles } from 'forge' +// or use define.Styles — same thing + + + + + + ... + +``` + +--- + +## 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, +}) + +//
...
+``` + +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 `
`. Use `base` to change the tag: + +```tsx +const Button = define('Button', { + base: 'button', + padding: 20, + cursor: 'pointer', +}) +// + +const Heading = define('Heading', { + base: 'h1', + fontSize: 28, +}) +//

...

+``` + +### Attribute shorthand + +Set default attributes right in the base string: + +```tsx +const Radio = define('Radio', { + base: 'input[type=radio]', +}) +// + +const Checkbox = define('Checkbox', { + base: 'input[type=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 + + + + +``` + +Generated CSS: + +```css +.Button { padding: 20px; background: blue; } +.Button.disabled { cursor: not-allowed; opacity: 0.3; } +.Button.rounded { border-radius: 999px; } +``` + +HTML output for ` +``` + +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 + + + +``` + +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 + + +``` + +--- + +## 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 ( + +
{props.title}
+ {props.children} +
{props.footer}
+
+ ) + }, +}) +``` + +```tsx + + This is the card body content. + +``` + +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 ( + + + {props.name}{props.verified && ' ✓'} + {props.bio} + + ) + }, +}) +``` + +```tsx + + +``` + +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 ( + + {props.items?.map((item: any) => ( + {item.label} + ))} + + ) + }, +}) +``` + +The `active` prop on `` 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 ( + + {props.label && } + {props.children} + {props.helper && {props.helper}} + {props.error && {props.error}} + + ) + }, +}) +``` + +```tsx + + + + + + + +``` + +### Destructuring props + +Destructure to separate custom props from HTML passthrough props: + +```tsx +render({ props: { title, subtitle, ...rest }, parts: { Root, H2, P } }) { + return ( + +

{title}

+

{subtitle}

+
+ ) +} +``` + +### 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 {props.children} +// } +``` + +--- + +## 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 +Home +About +``` + +### 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 ( + + + + + ) + }, +}) +``` + +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 ( + + + {props.tabs?.map((tab: any, i: number) => ( + <> + + {tab.label} + + ))} + + {props.tabs?.map((tab: any) => ( + {tab.content} + ))} + + ) + }, +}) +``` + +```tsx +First panel

}, + { id: 'two', label: 'Tab 2', content:

Second panel

}, + { id: 'three', label: 'Tab 3', content:

Third panel

}, + ]} +/> +``` + +### 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 + + +// 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 `` in the `` to inline all CSS: + +```tsx +import { define, Styles } from 'forge' + +const Page = () => ( + + + + + ... + +) +``` + +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 + +``` + +### Single-page apps + +In browser environments, Forge automatically injects a `