HypenHypen
Guide

Components

Complete reference of all built-in Hypen components

Components

Hypen provides a set of built-in components for building UIs. This guide covers all available components.

Layout Components

Column

Arranges children vertically.

Column {
    Text("First")
    Text("Second")
    Text("Third")
}
.gap(12)

Row

Arranges children horizontally.

Row {
    Text("Left")
    Spacer()
    Text("Right")
}
.gap(12)

Box / Container

Generic container with z-axis stacking (overlay).

Box {
    Image("background.jpg")
    Text("Overlay")
}

Center

Centers content both horizontally and vertically.

Center {
    Spinner()
}
.fillMaxSize(true)

Stack

Overlays children on the z-axis.

Stack {
    Card { Text("Base") }
    Badge("New")
}

Grid

Arranges a bound collection in a grid — data-driven, like List.

Grid(@state.cells, key: "id") {
    Card { Text("@{item.label}") }
}
.gridColumns(2)
.gap(16)

List

Scrollable, data-driven list — shorthand for ForEach(items: @state.items, key: "id").

List(@state.items) {
    Text("@{item.title}")
}

Spacer

Flexible empty space.

Row {
    Text("Left")
    Spacer()
    Text("Right")
}

Divider

Visual separator line.

Column {
    Text("Section 1")
    Divider()
    Text("Section 2")
}

SafeArea

Full-size vertical container that pads its content by the device safe-area insets (notch, status bar, home indicator). Use it as the outermost container of a screen.

SafeArea {
    Column {
        Text("Clear of the notch and the home indicator")
    }
}

Pass edges to inset only some sides — omitted or empty means all four:

SafeArea(edges: ["top"]) {
    Text("Only the top inset is applied")
}

Embedders can override the insets per edge; unspecified edges keep the platform default. See Layout → SafeArea for the per-renderer override APIs and the iOS Safari viewport-fit=cover caveat.

Content Components

Text

Displays text.

Text("Hello World")
    .fontSize(18)
    .fontWeight("bold")
    .color("#333333")

Heading

Heading text with larger default styling.

Heading("Page Title")
    .fontSize(32)

Paragraph

Paragraph text with appropriate line spacing.

Paragraph("Long text content that spans multiple lines...")
    .lineHeight(1.6)

Image

Displays an image.

Image("https://example.com/photo.jpg")
    .width(200)
    .height(200)
    .borderRadius(8)

Input Components

Button

Clickable button.

Button {
    Text("Submit")
}
.onClick(@actions.submit)
.backgroundColor("#3B82F6")
.padding(12)
.borderRadius(6)

Input

Single-line text input.

Input(placeholder: "Enter your name")
    .value(@state.name)
    .onInput(@actions.updateName)
    .padding(12)
    .borderRadius(4)

Textarea

Multi-line text input.

Textarea(placeholder: "Enter message...")
    .onInput(@actions.updateMessage)
    .height(100)

Checkbox

Toggle checkbox.

Checkbox {}
    .checked(@state.agreed)
    .onChange(@actions.toggleAgreed)

Switch

Toggle switch.

Switch {}
    .checked(@state.darkMode)
    .onChange(@actions.toggleDarkMode)

Select

Dropdown selection.

Select {}
    .value(@state.country)
    .onChange(@actions.updateCountry)

Slider

Range slider.

Slider {}
    .value(@state.volume)
    .onChange(@actions.updateVolume)

Display Components

Card

Container with elevation and rounded corners.

Card {
    Column {
        Text("Card Title")
        Text("Card content")
    }
    .padding(16)
}

Badge

Small label indicator.

Badge("3")
    .backgroundColor("#EF4444")

Avatar

User avatar display.

Avatar("https://example.com/user.jpg")
    .size(48)
    .borderRadius(24)

Spinner

Loading indicator.

Spinner()
    .size(24)

ProgressBar

Progress indicator.

ProgressBar(value: @state.progress)
    .height(4)

Icon

SVG icon rendered from a registered resource. Icons are registered server-side via resources() or resourcesDir() and resolved at render time — the client receives pre-resolved SVG path data, not raw SVG strings.

Icon(@resources.heart)
    .size(24)
    .color("#ed4956")

Reference a resource by name with the @resources prefix:

Icon(@resources.search)
    .size(20)
    .color("#8e8e8e")

Icon(@resources.plus-square)
    .size(28)

You can also use a static string (resolved against the same registry):

Icon("heart")
    .size(24)

Props:

  • @resources.name or "name" — resource name (positional)
  • size — icon size in pixels (default: 24)
  • color — icon color (default: "currentColor")

Registering resources (server-side):

// TypeScript
const server = new RemoteServer()
  .resources({
    heart: '<svg viewBox="0 0 24 24"><path d="M20.84 4.61..."/></svg>',
  })
// Or load from a JSON file (name → SVG map)
await server.resourcesFile("./resources.json")
// Or load all .svg files from a directory
await server.resourcesDir("./icons/")

Media Components

Video

Video player. Streams a resolved URL — or an ordered playlist of URLs with auto-advance — using the platform's native media stack. Media payloads never cross the wire, only URLs.

Video("https://example.com/video.mp4")
    .width(640)
    .height(360)

Props: src (or positional), playlist, startIndex, startPosition, poster, controls, autoplay, loop, muted, preload (web hint), headers (auth-protected streams), and title (accessible label). Optional event actions onPlay, onPause, onEnded, onTrackChange, and onError receive the track's src and playlist index (plus status/code/message on errors).

Video(
    playlist: ["https://cdn/ep1.mp4", "https://cdn/ep2.mp4"],
    poster: "https://cdn/cover.jpg",
    controls: true,
    title: "Season 1",
    onTrackChange: @actions.trackChanged,
    onEnded: @actions.playbackDone,
    onError: @actions.playbackFailed,
)

startPosition seeks once, the moment the source first becomes seekable — "resume where you left off" without any further wiring. It re-arms when the source configuration (src, playlist, headers) changes.

Playback control:

.bind(@state.playback) is the media analogue of Input.bind — it keeps a playback struct in sync with the player:

FieldTypeAccessMeaning
playingbooleanread-writePlay/pause. Writing true while ended restarts from 0.
positionnumberread-writeSeconds. Writing seeks.
durationnumberread-onlySeconds, 0 until known.
statestringread-onlyidle, loading, playing, paused, ended, or error.

The module must initialize the struct in defineState — binding writes into a missing parent path are dropped:

export default app
  .defineState({
    streamUrl: "",
    playback: { playing: true, position: 0, duration: 0, state: "idle" },
  })
  .onAction("togglePlay", ({ state }) => {
    state.playback.playing = !state.playback.playing;
  });

Reports flow the other way too. position updates at most every 250 ms while playing, so it is cheap to render; playing, state, and duration report immediately on every transition (play, pause, seek completion, track change, ended, error). A position write is only applied as a seek when it differs from the player's real position by more than a second, so the renderer's own progress reports never echo back as seeks.

Composition slots:

Children tagged with .slot(name) compose into the player chrome instead of being laid out as ordinary children. Slot content is normal Hypen UI — applicators, @actions, and @{state.*} bindings all work — the renderer just overlays it full-bleed on the video surface:

Video(src: "@{state.streamUrl}", title: "@{state.title}", autoplay: true) {
    Row {
        Button {
            Text("⏯")
        }
            .onClick(@actions.togglePlay)
            .label("Play or pause")

        Scrubber()
            .tw("flex-1 ml-3")
    }
        .tw("px-4 pb-4 items-center self-end w-full")
        .slot("controls")

    Column {
        Spinner()
        Text("Buffering…")
    }
        .tw("items-center justify-center w-full h-full")
        .slot("loading")
}
    .bind(@state.playback)
    .tw("w-full aspect-video rounded-2xl overflow-hidden")

The four slots are controls, loading, error, and poster, and the player state decides which are visible: poster shows in idle/loading/ended, loading only while loading or rebuffering, controls in idle, playing, paused, and ended, and error only in error. A present slot replaces the built-in for that concern — a controls slot suppresses native chrome regardless of the controls prop — and visibility is show/hide, not mount/unmount, so slot subtrees keep their state across transitions.

Fullscreen:

Fullscreen can't ride the normal action → module → state round trip: browsers only honor a fullscreen request inside the user's click, and a network hop can lose that activation. .videoIntent("fullscreen") marks an element inside a Video (typically a controls-slot button) as a renderer-local toggle — the tap is handled on-device, no module involvement:

Button { Icon(@resources.fullscreen) }
    .videoIntent("fullscreen")
    .label("Toggle fullscreen")

What goes fullscreen is the video container, never the raw platform video element — so composition slots stay overlaid and custom controls keep working; handing the element itself to the platform would swap in native chrome. Per platform: web DOM fullscreens the wrapper via the Fullscreen API, canvas fullscreens its host element, Android presents an immersive dialog hosting the same player, iOS uses a fullScreenCover with the same AVPlayer, and desktop fullscreens the window. Playback state, the bind, and slot visibility are unaffected — fullscreen is presentation only. .videoIntent composes with .onClick: both fire, and outside a Video subtree the applicator is inert.

There is no mute or volume intent — muting has no user-gesture requirement, so it stays declarative: bind the muted prop to state and toggle it from an action (muted: "@{state.muted}").

Scrubber

Media timeline for a Video's controls slot. Inside a Video it wires itself to the enclosing player renderer-side: the thumb tracks playback at frame rate without touching module state, dragging previews locally, and only the release commits — so scrubbing stays responsive even when a state round trip costs a network hop. It carries the slider accessibility role with live timeline values, and where the platform gives it keyboard focus, arrow keys seek ±5 seconds and commit immediately.

Scrubber()
    .bind(@state.playback)

The commit goes to the Scrubber's own .bind(...) if it has one, otherwise to the enclosing Video's bind, otherwise to its onSeek action (payload { type: "seek", position }). The local seek applies either way, so the playhead moves even with no bind at all. Outside a Video, Scrubber renders inert.

Audio

Audio player.

Audio("https://example.com/audio.mp3")

Navigation link.

Link(href: "/about") {
    Text("About Us")
}

Router / Route

Declarative routing driven by module state. Routes are matched by the platform renderer based on state.location. For a complete routing guide, see Routing.

Router {
    Route(path: "/home") {
        HomePage()
    }
    Route(path: "/users/:id") {
        UserProfile(userId: @state.params.id)
    }
    Route(path: "/settings") {
        SettingsPage()
    }
    Else {
        NotFoundPage()
    }
}

Router Arguments:

  • value (optional): State binding that drives the active route (defaults to @state.location). The initial route, path persistence, and module lifecycle are configured on the host SDK's ManagedRouter, not in the DSL.

Route Arguments:

  • path: URL pattern (supports :param segments)

An Else { ... } child renders when no route matches.

Control Flow Components

Control flow components are first-class constructs in the engine. They don't create DOM elements — their children render directly into the parent container. For a complete guide with patterns, performance tips, and end-to-end examples, see Control Flow.

ForEach

Iterates over a collection and renders children for each item. Use @item to reference each item, and always provide key for dynamic lists.

ForEach(items: @state.todos, key: "id") {
    Row {
        Checkbox {}
            .checked(@item.completed)
            .onChange(@actions.toggleTodo)

        Text(@item.title)
            .fontSize(16)
            .color("@{item.completed ? '#9CA3AF' : '#111827'}")
            .flex(1)

        Button { Text("Delete") }
            .onClick(@actions.deleteTodo)
            .color("#EF4444")
    }
    .padding(12)
    .gap(12)
    .verticalAlignment("center")
}

Arguments:

ArgumentRequiredDefaultDescription
items or inYes—Array binding from state
keyNoindexProperty name for stable identity across updates
asNo"item"Custom variable name for each element

When

Pattern matching on a value with multiple cases. Great for loading/error/success states.

When(value: @state.status) {
    Case(match: "loading") {
        Center { Spinner() }
    }
    Case(match: "error") {
        Column {
            Text("Something went wrong")
            Button { Text("Retry") }
                .onClick(@actions.reload)
        }
    }
    Case(match: "success") {
        ContentView()
    }
    Else {
        Text("Unknown status")
    }
}

Arguments:

  • value: The value to match against

Children:

  • Case(match: ...): One or more case branches
  • Else: Optional fallback when no Case matches

Match Patterns:

  • Exact values: "loading", 200, true
  • Multiple values (OR): [200, 201, 204]
  • Wildcards: "_" or "*"
  • Expressions: "@{value >= 90}"

If

Boolean conditional for simple true/false branching. Under the hood it's syntactic sugar over When.

If(condition: @state.isLoggedIn) {
    ProfileMenu()
    Else {
        LoginButton()
    }
}

// Without Else — just hide/show
If(condition: @state.hasNotifications) {
    Badge("New")
        .backgroundColor("#EF4444")
}

Arguments:

  • condition: Boolean binding or expression

Children:

  • Main content (rendered when truthy)
  • Else: Optional fallback (rendered when falsy)

Charts

Chart opens a coordinate space; its children (Line, Area, Bars, Points, Axis, Rule, Marker, Path) are marks positioned in data units, and their events carry the datum rather than a pixel. See the Charts guide.

Chart(y: [0, 100]) {
    Axis(x)
    Axis(y)
    Bars(data: @state.units, x: "month", y: "count").onClick(@actions.selectMonth)
}
.height(220)

Platform Support

ComponentWebAndroidiOS
Column, Row, BoxYesYesYes
Center, Stack, GridYesYesYes
List, Spacer, DividerYesYesYes
SafeAreaYesYesYes
Text, Heading, ParagraphYesYesYes
ImageYesYesYes
Button, Input, TextareaYesYesYes
Checkbox, Switch, SelectYesYesYes
SliderYesYesYes
Card, Badge, AvatarYesYesYes
Spinner, ProgressBarYesYesYes
Video, AudioYesYesYes
Video playback bind, slots, ScrubberYesYesYes
Link, Router, RouteYesYesYes
ForEach, When, IfYesYesYes
Chart, Axis, Line, Area, Bars, Points, Rule, Marker, PathYesYesYes

Video's playback bind, composition slots, and Scrubber ship on all five renderers — web DOM, web canvas, Android, iOS, and desktop. On desktop the playback-dependent parts need the video cargo feature (GStreamer); the slots themselves work without it for the idle and error states.

Next Steps

  • Child Slots — Build reusable components that accept children with Children() and named slots
  • Control Flow — Deep dive into ForEach, When, If with patterns and performance tips
  • Routing — Navigation, route guards, and nested routing
  • Layout — Layout components and alignment
  • Styling — Complete applicator reference
  • Inputs — Forms and user interaction