49 KiB
Hype + Forge
A complete guide to building web apps with Hype and Forge.
Hype is a thin wrapper around Hono for building web apps with Bun. It provides file-based routing, automatic TypeScript transpilation, SSE, and a default HTML5 layout — all without a build step. Since Hype extends Hono, every Hono API works out of the box.
Forge is a component library that generates real CSS from JSX definitions. Each define() call produces a typed component with scoped CSS classes. It works on both server and client — on the server it collects CSS for <Styles /> to inline, in the browser it auto-injects a <style> tag.
Table of Contents
Setup
Hype — Server Framework
- Server Entry Point
- Routing
- Layouts
- Pages
- Client-Side JavaScript
- Styling
- Server-Sent Events (SSE)
- Custom API Routes
- Sub-Routers
- Static Files
Forge — Components
- Basic Components
- HTML Tags
- CSS Properties
- States
- Variants
- Parts
- Custom Render
- Selectors
- Scopes
- Themes
Integration
Reference
Setup
Getting Started
bun add @because/hype @because/forge
mkdir -p src/server src/pages src/client src/shared src/css pub
Add scripts to package.json:
{
"scripts": {
"start": "bun run src/server/index.ts",
"dev": "bun run --hot src/server/index.ts"
}
}
Add the required tsconfig.json:
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"paths": {
"$*": ["src/server/*"],
"#*": ["src/client/*"],
"@*": ["src/shared/*"]
}
}
}
Create a server and page:
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype()
export default app.defaults
// src/pages/index.tsx
export default () => (
<section>
<h1>Hello, world!</h1>
</section>
)
bun dev
You have a server-rendered page at http://localhost:3000.
Project Structure
.
├── package.json
├── tsconfig.json
├── pub/ # Static files (served as-is at /)
│ └── img/
│ └── logo.png # => /img/logo.png
├── src/
│ ├── server/
│ │ └── index.ts # Server entry point
│ ├── pages/
│ │ ├── index.tsx # => GET /
│ │ ├── about.tsx # => GET /about
│ │ └── _layout.tsx # Custom layout (optional)
│ ├── client/
│ │ └── main.ts # Client JS (auto-included by default layout)
│ ├── shared/
│ │ ├── components.tsx # Forge components (shared between SSR and SPA)
│ │ └── themes.tsx # Forge themes
│ └── css/
│ └── main.css # App CSS (auto-included by default layout)
src/pages/— SSR pages, one file per routesrc/client/— Client-side TypeScript, transpiled and bundled on demandsrc/shared/— Isomorphic code, available to both server and clientsrc/css/— Stylesheetssrc/server/— Server-only codepub/— Static assets served directly
Hype — Server Framework
Server Entry Point
Always export app.defaults as the default export:
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype()
export default app.defaults
// => { port, fetch, idleTimeout: 255 }
This triggers lazy route registration and auto-port selection. Bun picks it up as the server config when the file is the entry point.
Routing
File-based routing
Files in src/pages/ are automatically mapped to GET routes:
| File | URL |
|---|---|
src/pages/index.tsx |
/ |
src/pages/about.tsx |
/about |
src/pages/contact.tsx |
/contact |
Files prefixed with _ are private and won't be served (returns 404). This is used for layouts and other internal files.
Custom routes
Since Hype extends Hono, you can define any route directly:
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype()
// JSON API
app.get('/api/users/:id', (c) => {
return c.json({ id: c.req.param('id'), name: 'Chris' })
})
// Form handling
app.post('/api/contact', async (c) => {
const body = await c.req.parseBody()
console.log('Message:', body.message)
return c.redirect('/thanks')
})
// Custom HTML
app.get('/custom', (c) => {
return c.html('<h1>Custom page</h1>')
})
export default app.defaults
Custom routes are defined before the file-based routes, so they take priority.
Layouts
Default layout
By default, Hype wraps every page in a simple HTML5 layout that includes:
<meta charset="utf-8"><meta name="viewport">for responsive design<meta name="color-scheme" content="light dark">for automatic dark mode<link>to/css/main.css(yoursrc/css/main.css)<script>loading/client/main.ts(yoursrc/client/main.ts)- Content wrapped in
<body><main>...</main></body>
Optionally includes the CSS reset and/or Pico CSS based on your config.
Custom layout
Create src/pages/_layout.tsx to replace the default layout:
// src/pages/_layout.tsx
import type { FC } from 'hono/jsx'
const Layout: FC = ({ children, title, props }) => (
<html lang="en">
<head>
<title>{title ?? 'My App'}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="/css/main.css" rel="stylesheet" />
<script src="/client/main.ts" type="module"></script>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>{children}</main>
<footer>Built with Hype</footer>
</body>
</html>
)
export default Layout
The layout receives:
children— the rendered page contenttitle— page title (defaults to'hype')props— theHypePropspassed to the constructor (useful for conditional CSS)
No layout
Disable the layout entirely for full control (used in SPA mode):
const app = new Hype({ layout: false })
Pages
Pages are .tsx files in src/pages/ that export a default function (or raw JSX).
Function export (recommended)
// src/pages/index.tsx
export default ({ c, req }) => (
<section>
<h1>Home</h1>
</section>
)
Static JSX export
// src/pages/about.tsx
export default (
<section>
<h1>About</h1>
</section>
)
Accessing the request
Page components receive c (the Hono context) and req (the Hono request) as props:
// src/pages/greet.tsx
export default ({ req }) => (
<section>
<h1>Hello, {req.query('name') ?? 'stranger'}!</h1>
</section>
)
For the full Hono context:
// src/pages/debug.tsx
export default ({ c, req }) => {
const ua = req.header('user-agent')
return (
<section>
<h1>Request Info</h1>
<p>URL: {req.url}</p>
<p>User-Agent: {ua}</p>
</section>
)
}
Async data in pages
Since pages render on the server, you can use top-level await:
// src/pages/users.tsx
const users = await fetch('https://api.example.com/users').then(r => r.json())
export default () => (
<section>
<h1>Users</h1>
<ul>
{users.map(u => <li>{u.name}</li>)}
</ul>
</section>
)
Note: top-level data is fetched once at import time and cached. For per-request data, use the c context:
// src/pages/profile.tsx
export default async ({ c, req }) => {
const userId = req.query('id')
const user = await fetch(`https://api.example.com/users/${userId}`).then(r => r.json())
return (
<section>
<h1>{user.name}</h1>
</section>
)
}
Private pages
Prefix a file with _ to prevent it from being served:
src/pages/_layout.tsx # not a route — used as the layout
src/pages/_helpers.tsx # not a route — internal helpers
src/pages/index.tsx # GET /
Client-Side JavaScript
Automatic transpilation
TypeScript files in src/client/ and src/shared/ are automatically transpiled and bundled by Bun when requested by the browser. The URL maps directly to the file path:
| File | URL |
|---|---|
src/client/main.ts |
/client/main.ts |
src/client/app.tsx |
/client/app.js |
src/shared/utils.ts |
/shared/utils.ts |
You can request .ts or .js extensions — Hype resolves .ts and .tsx files automatically.
Module imports
Client-side files can import from each other using relative paths:
// src/client/main.ts
import { initBurger } from './burger'
initBurger()
// src/client/burger.ts
export function initBurger() {
document.addEventListener('click', (ev) => {
const el = (ev?.target as HTMLElement).closest('.burger') as HTMLImageElement
if (!el) return
el.src = '/img/bite.png'
})
}
Imports are bundled — the full dependency graph is included in the output, so the browser only needs one request.
The default layout auto-includes main.ts
When using the default layout, src/client/main.ts is automatically loaded as a module. Just create the file and it works.
Styling
External CSS
Put your styles in src/css/main.css. The default layout auto-includes it:
/* src/css/main.css */
section {
max-width: 500px;
margin: 0 auto;
}
You can also serve additional CSS files from src/css/:
<link href="/css/components.css" rel="stylesheet" />
Pico CSS
Enable the bundled Pico CSS for classless styling:
const app = new Hype({ pico: true })
Or include it in a custom layout:
<link href="/css/pico.css" rel="stylesheet" />
CSS Reset
Enable the bundled CSS reset (Josh W. Comeau's reset):
const app = new Hype({ reset: true })
Or include it in a custom layout:
<link href="/css/reset.css" rel="stylesheet" />
Combining options
const app = new Hype({ pico: true, reset: true })
Server-Sent Events (SSE)
Hype provides app.sse() for streaming data to the browser.
Server
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype()
// Stream the current time every second
app.sse('/api/time', (send) => {
send({ time: Date.now() })
const interval = setInterval(() => send({ time: Date.now() }), 1000)
return () => clearInterval(interval) // cleanup on disconnect
})
export default app.defaults
The send function:
- Automatically
JSON.stringifys objects; strings are sent as-is - Accepts an optional second argument for named events:
send(data, 'eventName')
The handler receives (send, c) where c is the Hono context:
app.sse('/api/user-events', (send, c) => {
const userId = c.req.query('userId')
// subscribe to user-specific events...
})
Return a cleanup function to handle client disconnection.
Client
// src/pages/sse.tsx
export default () => (
<section>
<h1>SSE Demo</h1>
<div id="time" style="font-size: 2em; font-family: monospace;"></div>
<script src="/client/main.ts" type="module"></script>
</section>
)
// src/client/main.ts
const timeEl = document.getElementById('time')
const events = new EventSource('/api/time')
events.onmessage = (e) => {
const data = JSON.parse(e.data)
timeEl!.textContent = new Date(data.time).toLocaleTimeString()
}
events.onerror = () => {
timeEl!.textContent = 'Disconnected'
}
Named events
// Server
app.sse('/api/feed', (send) => {
send({ type: 'user_joined', name: 'Chris' }, 'activity')
send({ message: 'Hello everyone!' }, 'chat')
})
// Client
const events = new EventSource('/api/feed')
events.addEventListener('activity', (e) => {
console.log('Activity:', JSON.parse(e.data))
})
events.addEventListener('chat', (e) => {
console.log('Chat:', JSON.parse(e.data))
})
Test with curl
curl -N http://localhost:3000/api/time
Custom API Routes
Since Hype extends Hono, use any Hono routing method:
import { Hype } from '@because/hype'
const app = new Hype()
// GET with params
app.get('/api/users/:id', (c) => {
return c.json({ id: c.req.param('id') })
})
// POST with body parsing
app.post('/api/users', async (c) => {
const body = await c.req.json()
return c.json({ created: true, name: body.name }, 201)
})
// Form submission
app.post('/contact', async (c) => {
const form = await c.req.parseBody()
console.log(form.email, form.message)
return c.redirect('/')
})
// Delete
app.delete('/api/users/:id', (c) => {
return c.json({ deleted: true })
})
export default app.defaults
Redirect back
Use redirectBack() to redirect to the referrer:
import { Hype, redirectBack } from '@because/hype'
const app = new Hype()
app.post('/api/like', async (c) => {
// ... handle the like
return redirectBack(c, '/') // falls back to '/' if no referrer
})
Sub-Routers
Use Hype.router() to create sub-routers without duplicate middleware:
// src/server/index.ts
import { Hype } from '@because/hype'
import api from './api'
const app = new Hype()
app.route('/api', api)
export default app.defaults
// src/server/api.ts
import { Hype } from '@because/hype'
const api = Hype.router()
api.get('/users', (c) => c.json([{ id: 1, name: 'Chris' }]))
api.get('/users/:id', (c) => c.json({ id: c.req.param('id') }))
api.post('/users', async (c) => {
const body = await c.req.json()
return c.json(body, 201)
})
export default api
Hype.router() creates a Hype instance that skips middleware registration (no duplicate logging, static file serving, etc.).
Static Files
Files in pub/ are served at the root URL:
pub/img/logo.png => /img/logo.png
pub/favicon.ico => /favicon.ico
pub/robots.txt => /robots.txt
Use them in pages:
export default () => (
<section>
<img src="/img/logo.png" width="200" />
</section>
)
Forge — Components
Basic Components
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>
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)'
Integration
SSR with Forge
In SSR mode, Forge components render on the server and <Styles /> inlines all generated CSS into the page. Because Hype imports pages at startup, all define() calls run before any request — so <Styles /> always has the complete CSS.
Server
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype({ layout: false })
export default app.defaults
Layout with <Styles />
// src/pages/_layout.tsx
import type { FC } from 'hono/jsx'
import { Styles } from 'forge'
const Layout: FC = ({ children, title }) => (
<html lang="en">
<head>
<title>{title ?? 'My App'}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<Styles />
</head>
<body data-theme="dark">{children}</body>
</html>
)
export default Layout
Shared components
// src/shared/components.tsx
import { define } from 'forge'
export const Button = define('Button', {
base: 'button',
padding: '10px 20px',
background: '#111',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
states: {
hover: { background: '#333' },
},
variants: {
intent: {
primary: { background: 'blue' },
danger: { background: 'red' },
},
},
})
export const Card = define('Card', {
padding: 24,
background: '#111',
borderRadius: 8,
border: '1px solid #222',
parts: {
Title: { base: 'h2', fontSize: 20, marginBottom: 12 },
Body: { fontSize: 14, color: '#888', lineHeight: 1.6 },
},
render({ props, parts: { Root, Title, Body } }) {
return (
<Root>
<Title>{props.title}</Title>
<Body>{props.children}</Body>
</Root>
)
},
})
Pages
Use Forge components directly in Hype pages — they're just JSX:
// src/pages/index.tsx
import { Button, Card } from '@components'
export default () => (
<main>
<Card title="Welcome">
This page is server-rendered with Forge components.
</Card>
<Button intent="primary">Get Started</Button>
</main>
)
Adding client interactivity to SSR
For interactivity on SSR pages, use a client-side script alongside Forge components:
// src/pages/index.tsx
import { Button } from '@components'
export default () => (
<section>
<Button intent="primary" id="my-btn">Click me</Button>
<script src="/client/main.ts" type="module"></script>
</section>
)
// src/client/main.ts
document.getElementById('my-btn')?.addEventListener('click', () => {
alert('Clicked!')
})
SSR with Forge + SSE
A server-rendered page with styled components that updates live:
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype({ layout: false })
let count = 0
app.sse('/api/counter', (send) => {
send({ count })
const interval = setInterval(() => send({ count: ++count }), 1000)
return () => clearInterval(interval)
})
export default app.defaults
// src/pages/index.tsx
import { Card, Badge } from '@components'
export default () => (
<section>
<Card title="Live Counter">
Count: <Badge><span id="count">--</span></Badge>
</Card>
<script src="/client/main.ts" type="module"></script>
</section>
)
// src/client/main.ts
const el = document.getElementById('count')!
const events = new EventSource('/api/counter')
events.onmessage = (e) => {
el.textContent = JSON.parse(e.data).count
}
SPA with Forge
In SPA mode, Hype serves a minimal HTML shell and Forge runs entirely in the browser. Forge auto-injects a <style id="forge-styles"> tag into document.head — no <Styles /> needed.
Server
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype({ layout: false })
export default app.defaults
HTML Shell
The page is just a mount point. No Forge imports here — everything runs client-side:
// src/pages/index.tsx
export default () => (
<html lang="en">
<head>
<title>My SPA</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
</head>
<body data-theme="dark">
<div id="root" />
<script src="/client/app.js" type="module" />
</body>
</html>
)
No <Styles />, no CSS link. Forge handles it in the browser.
Client entry
// src/client/app.tsx
import { render } from 'hono/jsx/dom'
import { useState } from 'hono/jsx/dom'
import { define } from 'forge'
const Button = define('Button', {
base: 'button',
padding: '10px 20px',
background: 'blue',
color: 'white',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
states: {
hover: { background: 'darkblue' },
},
})
const Card = define('Card', {
padding: 24,
background: '#111',
borderRadius: 8,
})
function App() {
const [count, setCount] = useState(0)
return (
<Card>
<h1>Count: {count}</h1>
<Button onClick={() => setCount(c => c + 1)}>Increment</Button>
</Card>
)
}
render(<App />, document.getElementById('root')!)
When define() runs in the browser, Forge immediately injects the CSS. Hype's auto-transpilation bundles everything — Forge included — into a single JS file served at /client/app.js.
Extracting components
Keep components in separate files and import them. Hype's bundler resolves all imports:
// src/client/components.tsx
import { define } from 'forge'
export const Button = define('Button', {
base: 'button',
padding: '10px 20px',
background: 'blue',
color: 'white',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
})
export const Stack = define('Stack', {
display: 'flex',
flexDirection: 'column',
gap: 16,
})
// src/client/app.tsx
import { render } from 'hono/jsx/dom'
import { Button, Stack } from './components'
function App() {
return (
<Stack>
<h1>My App</h1>
<Button>Click me</Button>
</Stack>
)
}
render(<App />, document.getElementById('root')!)
Passing server data to the SPA
Inject data from the server into the HTML shell, then read it client-side:
// src/pages/index.tsx
const config = {
apiUrl: process.env.API_URL ?? '/api',
version: '1.0.0',
}
export default () => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body data-theme="dark">
<div id="root" />
<script dangerouslySetInnerHTML={{
__html: `window.__CONFIG__ = ${JSON.stringify(config)};`
}} />
<script src="/client/app.js" type="module" />
</body>
</html>
)
// src/client/app.tsx
const config = (window as any).__CONFIG__
Use dangerouslySetInnerHTML — Hono's JSX escapes <script> content by default.
Cache-busting
Use the git hash to bust asset caches:
// src/pages/index.tsx
import { $ } from 'bun'
const GIT_HASH = await $`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => 'dev')
export default () => (
<html lang="en">
<head>
<link href={`/css/main.css?${GIT_HASH}`} rel="stylesheet" />
</head>
<body>
<div id="root" />
<script src={`/client/app.js?${GIT_HASH}`} type="module" />
</body>
</html>
)
SPA with API routes
Hype serves the shell and API; Forge renders the client:
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype({ layout: false })
const todos: { id: number; text: string }[] = []
let nextId = 1
app.get('/api/todos', (c) => c.json(todos))
app.post('/api/todos', async (c) => {
const { text } = await c.req.json()
const todo = { id: nextId++, text }
todos.push(todo)
return c.json(todo, 201)
})
app.delete('/api/todos/:id', (c) => {
const id = Number(c.req.param('id'))
const idx = todos.findIndex(t => t.id === id)
if (idx !== -1) todos.splice(idx, 1)
return c.json({ deleted: true })
})
export default app.defaults
// src/client/app.tsx
import { render } from 'hono/jsx/dom'
import { useState, useEffect } from 'hono/jsx/dom'
import { define } from 'forge'
const Stack = define('Stack', {
display: 'flex',
flexDirection: 'column',
gap: 12,
padding: 24,
maxWidth: 500,
margin: '0 auto',
})
const TodoItem = define('TodoItem', {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 12,
background: '#111',
borderRadius: 6,
})
const Button = define('Button', {
base: 'button',
padding: '8px 16px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
states: {
hover: { background: '#2563eb' },
},
variants: {
danger: {
background: '#ef4444',
states: { hover: { background: '#dc2626' } },
},
},
})
const Input = define('Input', {
base: 'input',
padding: 10,
background: '#111',
color: '#fff',
border: '1px solid #333',
borderRadius: 6,
flex: 1,
marginRight: 8,
})
function App() {
const [todos, setTodos] = useState<{ id: number; text: string }[]>([])
const [text, setText] = useState('')
useEffect(() => {
fetch('/api/todos').then(r => r.json()).then(setTodos)
}, [])
const add = async () => {
if (!text.trim()) return
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
const todo = await res.json()
setTodos(prev => [...prev, todo])
setText('')
}
const remove = async (id: number) => {
await fetch(`/api/todos/${id}`, { method: 'DELETE' })
setTodos(prev => prev.filter(t => t.id !== id))
}
return (
<Stack>
<h1>Todos</h1>
<div style={{ display: 'flex' }}>
<Input
value={text}
onInput={(e: any) => setText(e.target.value)}
placeholder="What needs doing?"
/>
<Button onClick={add}>Add</Button>
</div>
{todos.map(todo => (
<TodoItem key={todo.id}>
<span>{todo.text}</span>
<Button danger onClick={() => remove(todo.id)}>Delete</Button>
</TodoItem>
))}
</Stack>
)
}
render(<App />, document.getElementById('root')!)
Shared Components
When a component has no client-side hooks or event handlers, it works identically in both SSR pages and SPA code. Put these in src/shared/:
// src/shared/components.tsx
import { define } from 'forge'
export const Badge = define('Badge', {
base: 'span',
display: 'inline-block',
padding: '4px 10px',
fontSize: 12,
borderRadius: 999,
background: '#3b82f6',
color: 'white',
variants: {
muted: {
background: '#333',
color: '#888',
},
},
})
Use it in an SSR page:
// src/pages/index.tsx
import { Badge } from '@components'
export default () => (
<section>
<h1>Status: <Badge>Active</Badge></h1>
</section>
)
Use the same component in a SPA:
// src/client/app.tsx
import { Badge } from '@components'
function StatusBar() {
return <Badge muted>Draft</Badge>
}
Hype's bundler handles the import in both cases. On the server, define() registers CSS that <Styles /> collects. In the browser, define() injects CSS into the document head.
Choosing SSR vs SPA
| SSR | SPA | |
|---|---|---|
| Styles | <Styles /> in the layout head |
Auto-injected by Forge in browser |
| Interactivity | Client scripts via /client/main.ts |
Full hono/jsx/dom with hooks |
| Routing | File-based, one page per route | Client-side, single HTML shell |
| Data | Fetch in page components (c, req) |
Fetch in useEffect, SSE, etc. |
| Best for | Content sites, dashboards, forms | Interactive apps, real-time UIs |
Both approaches use the same Forge components. The only difference is where define() runs (server vs browser) and how the CSS gets into the page (<Styles /> vs auto-injection).
Reference
Configuration
Constructor options
const app = new Hype({
pico: true, // Include Pico CSS (default: false)
reset: true, // Include CSS reset (default: false)
prettyHTML: true, // Pretty-print HTML in dev (default: true in dev, false in prod)
layout: true, // Wrap pages in layout (default: true)
logging: true, // HTTP request logging (default: true)
ok: true, // Add GET /ok healthcheck (default: false)
})
Environment Variables
| Variable | Effect |
|---|---|
PORT |
Server port (default: 3000) |
NODE_ENV |
Set to production to disable auto-port, HTML prettification, and enable transpile caching |
NO_AUTOPORT |
Disable auto-port selection even in dev |
In dev mode, if port 3000 is busy, Hype automatically tries 3001, 3002, etc. (up to 100 attempts).
Path Aliases
The recommended tsconfig.json defines three path aliases:
| Alias | Maps to | Use for |
|---|---|---|
$* |
src/server/* |
Server-only imports |
#* |
src/client/* |
Client-only imports |
@* |
src/shared/* |
Isomorphic imports |
// In server code:
import { db } from '$db' // => src/server/db.ts
// In client code:
import { format } from '@utils' // => src/shared/utils.ts
Utility Functions
Hype exports a collection of helpers from @because/hype (or @because/hype/utils):
Randomness
import { rand, randRange, randItem, randIndex, shuffle, weightedRand, randomId } from '@because/hype'
rand() // 1 or 2 (coin flip)
rand(6) // 1-6 (roll a die)
rand(20) // 1-20 (d20)
randRange(5, 10) // 5-10 inclusive
randItem(['a', 'b', 'c']) // random element
randIndex(['a', 'b', 'c']) // random index (0, 1, or 2)
shuffle([1, 2, 3, 4, 5]) // shuffled copy
weightedRand() // 1-10, lower numbers more likely
randomId() // e.g. "k7x2m1"
Arrays
import { times, unique } from '@because/hype'
times(5) // [1, 2, 3, 4, 5]
unique([1, 1, 2, 2, 3, 3]) // [1, 2, 3]
Colors
import { lightenColor, darkenColor } from '@because/hype'
lightenColor('#3498db', 0.5) // blend halfway to white
darkenColor('#3498db', 0.5) // blend halfway to black
Strings
import { capitalize } from '@because/hype'
capitalize('hello') // "Hello"
Dark mode detection (client-side)
import { isDarkMode } from '@because/hype'
if (isDarkMode()) {
// user prefers dark mode
}
Transpilation
import { transpile } from '@because/hype'
const js = await transpile('./src/client/app.tsx')
// => bundled ESM JavaScript string
Forge SSR helpers
// Inline all Forge CSS as a <style> tag
import { Styles } from 'forge'
<Styles />
// Get raw CSS string (e.g. to write to a file)
import { stylesToCSS } from 'forge'
const css = stylesToCSS()
Bun.write('public/main.css', css)
Forge HMR
Forge automatically clears and re-registers styles when modules are hot-reloaded via import.meta.hot.dispose. No setup needed.
Hono Middleware
Any Hono middleware works with Hype:
import { Hype } from '@because/hype'
import { cors } from 'hono/cors'
import { basicAuth } from 'hono/basic-auth'
const app = new Hype()
// CORS for API routes
app.use('/api/*', cors())
// Basic auth for admin
app.use('/admin/*', basicAuth({
username: 'admin',
password: process.env.ADMIN_PASSWORD!,
}))
export default app.defaults