hype/docs/HYPE+FORGE.md

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

Forge — Components

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 route
  • src/client/ — Client-side TypeScript, transpiled and bundled on demand
  • src/shared/ — Isomorphic code, available to both server and client
  • src/css/ — Stylesheets
  • src/server/ — Server-only code
  • pub/ — 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 (your src/css/main.css)
  • <script> loading /client/main.ts (your src/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 content
  • title — page title (defaults to 'hype')
  • props — the HypeProps passed 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).

// 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