These examples live in the Gyral repository as its acceptance suite: each one has tests, and the excerpts below are copied from the real files. To run them all, clone the repository, then pnpm install and pnpm examples.
Basics
Hello world
Two fields and a checkbox, and a greeting derived from state: input intents, reducers and ?checked, with valid and invalid input as plain values.
import { css, define, html } from '@gyral/core';import { every } from '@gyral/time';export interface State { /** The wall-clock time of the first tick (ms). */ readonly since: number | undefined; readonly seconds: number;}export type Msg = { readonly _tag: 'Tick'; readonly now: number };/** ISO 8601 duration for `<time datetime>`, e.g. 75 → "PT75S". */export const duration = (seconds: number): string => `PT${String(seconds)}S`;// A clock subscription: `every` sends the time at once and then once a second, for as long as// the component is in the page (ADR 0025). Seconds are counted from the first tick.export const SecondsElapsed = define<State, Msg>()('gy-seconds-elapsed', { init: () => ({ since: undefined, seconds: 0 }), subscriptions: () => [every(1000, (now) => ({ _tag: 'Tick', now }))], update: { Tick: (s, m) => { const since = s.since ?? m.now; return { since, seconds: Math.round((m.now - since) / 1000) }; }, }, view: (s) => html` <p> Seconds elapsed <output aria-live="off"><time datetime=${duration(s.seconds)}>${s.seconds}</time></output> </p> `, styles: css`…`,});
Heavier work under the same rules: a module worker behind a driver that a subscription starts and cancels, timed steps, a booking form with retries, and a canvas grid with pointer capture.
import { child, css, define, each, html, nothing, outputs, prop, type GyralElementClass,} from '@gyral/core';export interface State { /** Ids of this folder's direct children. Each child owns its own subtree. */ readonly children: readonly string[]; readonly next: number;}export interface Props { readonly folderId: string; readonly removable: boolean;}export type FolderOutput = { readonly _tag: 'Removed' };/** Typed `emit` for this component's outputs. */const emit = outputs<FolderOutput>();export type Msg = | { readonly _tag: 'Add' } | { readonly _tag: 'Remove' } | { readonly _tag: 'Child'; readonly id: string; readonly out: FolderOutput };/** Stable pastel hue per id, so a folder keeps its color across renders. */export function hueOf(id: string): number { let hue = 7; for (let n = 0; n < id.length; n += 1) hue = (hue * 31 + id.charCodeAt(n)) % 360; return hue;}/** * A folder renders folders of its own kind: recursion through the custom-element tag. * Each folder owns the list of its direct children (no single state tree with lenses per * level); a child removes itself by emitting `Removed` up one level. * Ids are paths (`1.2.1`), so making a new one is pure. *//** One child folder; its intent name comes through `pick`, so the row stays pure. */const subfolder = (id: string, intent: string) => html`<li> <gy-folder folder-id=${id} removable data-intent=${intent}></gy-folder> </li>`;// The explicit type lets `child(() => Folder, …)` refer to the constant being defined.export const Folder: GyralElementClass<State, Msg, Props, FolderOutput> = define< State, Msg, Props, FolderOutput>()('gy-folder', { props: { folderId: prop.string({ required: true }), // attribute "folder-id" // A boolean attribute: absent on the root folder means "not removable" (found by ui:check). removable: prop.boolean(), }, init: () => ({ children: [], next: 1 }), intent: { Add: true, Remove: true, // Lazy source: `Folder` is still being defined here (recursion), so pass a function. Child: child( () => Folder, (out, el) => ({ _tag: 'Child', id: el.folderId, out }), ), }, update: { Add: (s, _m, { props }) => ({ children: [...s.children, `${props.folderId}.${String(s.next)}`], next: s.next + 1, }), Remove: (s) => [s, [emit({ _tag: 'Removed' })]], // FolderOutput has one variant (Removed); switch on m.out._tag when it grows. Child: (s, m) => ({ ...s, children: s.children.filter((id) => id !== m.id) }), }, view: (s, i, { props }) => html` <details open style="--hue: ${hueOf(props.folderId)}"> <summary>Folder ${props.folderId}</summary> <menu> <li><button type="button" data-intent=${i.Add}>Add folder</button></li> ${ props.removable ? html`<li><button type="button" data-intent=${i.Remove}>Remove me</button></li>` : nothing } </menu> ${ s.children.length === 0 ? nothing : html`<ul> ${each( s.children, (id) => id, subfolder, () => i.Child, )} </ul>` } </details> `, styles: css`…`,});
import { command, css, define, html, nothing, type Head } from '@gyral/core';import { listen, router, routes, setHead, type RouteLocation, type RouteMatch,} from '@gyral/router';/** The route table: pure, typed, and reusable on a server (ADR 0009). */export const app = routes({ home: '/', about: '/about', contacts: '/contacts' });type Route = RouteMatch<typeof app.table>;type Name = Route['name'];export interface State { /** `undefined` until the router delivers the current location. */ readonly location: RouteLocation | undefined; readonly route: Route | undefined;}export type Msg = { readonly _tag: 'Routed'; readonly location: RouteLocation } | { readonly _tag: 'Back' };interface Page { readonly title: string; readonly nav: string; readonly body: string;}const pages: Readonly<Record<Name, Page>> = { home: { nav: 'Home', title: 'Welcome to the routing example', body: 'Links below are plain anchors. The router captures same-origin clicks, updates history, and streams every location to this component as a message.', }, about: { nav: 'About', title: 'About me', body: 'The current route is model state, derived from the location by matching it against a typed route table. The view is a pure function of that state.', }, contacts: { nav: 'Contacts', title: 'Contact me', body: 'Try the browser back button, or the Back button below: both arrive here as the same Routed message.', },};const order: readonly Name[] = ['home', 'about', 'contacts'];/** * The document head for a route: one pure function, so a server could reuse it (ADR 0019). * An unknown route is `noindex`; a known one names its canonical URL. */export const pageHead = (route: Route | undefined, origin: string): Head => ({ title: `${route === undefined ? 'Page not found' : pages[route.name].nav} — Gyral routing`, ...(route === undefined ? { robots: 'noindex' } : { canonical: new URL(route.path, origin).href }),});export const RoutingView = define<State, Msg>()('gy-routing-view', { init: () => ({ location: undefined, route: undefined }), // One subscription: the current location now, then every change (ADR 0009, ADR 0025). subscriptions: () => [listen((location) => ({ _tag: 'Routed', location }))], intent: { Back: true, }, update: { Routed: (_s, { location }) => { const route = app.match(location.href); // A client-only app: the page's own origin is the canonical one. return [{ location, route }, [setHead(pageHead(route, new URL(location.href).origin))]]; }, // History back is a router input (the "History back and forward" recipe). Back: (s) => [s, [command(router, { _tag: 'Traverse', delta: -1 })]], }, // Cross-fade between pages (an enhancement: skipped without support or with reduced motion). // The first location is not a page change, so it renders directly. viewTransition: (prev, next) => prev.location !== undefined && prev.route?.name !== next.route?.name, view: (s, i) => { const page = s.route === undefined ? undefined : pages[s.route.name]; return html` <header> <nav aria-label="Main"> <ul> ${order.map( (name) => html`<li> <a href=${app.href(name, {})} aria-current=${s.route?.name === name ? 'page' : nothing} >${pages[name].nav}</a > </li>`, )} </ul> </nav> </header> <main> ${ s.location === undefined ? nothing : page === undefined ? html`<h1>404 — page not found</h1> <p>No route matches <code>${s.location.pathname}</code>.</p>` : html`<h1>${page.title}</h1> <p>${page.body}</p>` } <p><button type="button" data-intent=${i.Back}>Back</button></p> <details> <summary>Location object</summary> <pre><code>${JSON.stringify(s.location, null, 2)}</code></pre> </details> </main> `; }, styles: css`…`,});
import { css, define, html, prop } from '@gyral/core';import { listen, setHead, type RouteLocation } from '@gyral/router';import { pageHead, site, titles } from './routes.js';export interface Props { /** The request path, set by the server. On the client the router takes over. */ readonly path?: string;}export interface State { readonly path: string;}export type Msg = { readonly _tag: 'Routed'; readonly location: RouteLocation };const menu = (path: string) => html` <nav aria-label="Site"> <ul> ${(['home', 'about'] as const).map((name) => { const href = site.href(name, {}); return html`<li> <a href=${href} aria-current=${href === path ? 'page' : 'false'}>${titles[name]}</a> </li>`; })} </ul> </nav>`;const content = (path: string) => { switch (site.match(path)?.name) { case 'home': return html`<h1>${titles.home}</h1> <p>Welcome to our spectacular web page with nothing special here.</p>`; case 'about': return html`<h1>${titles.about}</h1> <p>This is the page where we describe ourselves.</p> <gy-iso-contact></gy-iso-contact>`; case undefined: return html`<h1>Page not found</h1> <p>Unknown page <code>${path}</code>.</p>`; }};export const App = define<State, Msg, Props>()('gy-iso-app', { props: { path: prop.string() }, // Same on server and client. init: (props) => ({ path: props.path ?? '/' }), // The server never runs commands; after hydration the client subscribes to the location, // which streams the current one and every navigation (ADR 0012, ADR 0025). subscriptions: () => [listen((location) => ({ _tag: 'Routed', location }))], update: { // pageHead() also renders the server's head: one source for both (ADR 0019). The first // Routed after hydration finds the server's head already matching and writes nothing. Routed: (_s, m) => [{ path: m.location.pathname }, [setHead(pageHead(m.location.pathname))]], }, view: (s) => html` <header>${menu(s.path)}</header> <main>${content(s.path)}</main> `, styles: css`…`,});
import { css, define, html, keyed } from '@gyral/core';// The model only counts runs; the motion is a CSS animation the browser runs on its own// (no per-frame state, no JS timer, no coordinates tweened in JS)./** A keyframe: time (0..1), position in units of the travel distance, easing to the next stop. */export interface Stop { readonly at: number; readonly x: number; readonly y: number; readonly ease: string;}/** The three legs of the original, in milliseconds: right, down, then a curve back home. */const LEGS = [500, 500, 1600] as const;export const DURATION_MS = LEGS[0] + LEGS[1] + LEGS[2];const EASE_IN_CUBIC = 'cubic-bezier(0.32, 0, 0.67, 0)'; // tween.power3.easeInconst EASE_OUT_CUBIC = 'cubic-bezier(0.33, 1, 0.68, 1)'; // tween.power3.easeOutconst ARC_STEPS = 12;/** Inverse of easeInOutCubic: the fraction of the arc's time at which it reaches `progress`. */const arcTime = (progress: number): number => progress < 0.5 ? Math.cbrt(progress / 4) : 1 - Math.cbrt(2 * (1 - progress)) / 2;const round = (n: number): number => Math.round(n * 1e4) / 1e4 || 0; // `|| 0` drops -0/** * The original path as keyframe stops. The quarter circle (centre at (1, 0), from (1, 1) back to * (0, 0)) is sampled so its angle follows easeInOutCubic, like `tween.power3.easeInOut`. */export function path(): readonly Stop[] { const right = LEGS[0] / DURATION_MS; const down = (LEGS[0] + LEGS[1]) / DURATION_MS; const stops: Stop[] = [ { at: 0, x: 0, y: 0, ease: EASE_IN_CUBIC }, { at: right, x: 1, y: 0, ease: EASE_OUT_CUBIC }, { at: down, x: 1, y: 1, ease: 'linear' }, ]; for (let k = 1; k <= ARC_STEPS; k++) { const angle = (Math.PI / 2) * (1 + k / ARC_STEPS); stops.push({ at: round(down + (1 - down) * arcTime(k / ARC_STEPS)), x: round(1 + Math.cos(angle)), y: round(Math.sin(angle)), ease: 'linear', }); } return stops;}const offset = (n: number): string => `calc(var(--travel) * ${String(n)})`;/** `@keyframes travel` for the stops, using the individual `translate` property. */export function keyframes(stops: readonly Stop[]): string { const frames = stops.map( (s) => `${(s.at * 100).toFixed(2)}% { translate: ${offset(s.x)} ${offset(s.y)}; ` + `animation-timing-function: ${s.ease}; }`, ); return `@keyframes travel { ${frames.join(' ')} }`;}export interface State { /** How many times the animation was started. Each run gets a fresh element. */ readonly runs: number;}export type Msg = { readonly _tag: 'Animate' };/** One run's element; keyed by the run number, so each run gets a fresh one. */const target = (run: number) => html`<div class=${run === 0 ? 'target' : 'target moving'}></div>`;export const Animation = define<State, Msg>()('gy-animation', { init: () => ({ runs: 0 }), intent: { Animate: true }, update: { Animate: (s) => ({ runs: s.runs + 1 }) }, view: (s, i) => html` <button type="button" data-intent=${i.Animate}>Animate it!</button> <div class="stage" aria-hidden="true"> ${ // A new key per run replaces the element, which restarts the CSS animation. keyed(s.runs, target(s.runs)) } </div> `, styles: css`…`,});
import { css, define, each, html, type Next } from '@gyral/core';import { delay } from '@gyral/time';import { letterKeys } from './keyboard.js';// The model only knows which letters are present or leaving; CSS transitions do the growing// and shrinking, so there is no per-frame easing loop over the letters./** How long a letter takes to grow in or shrink out. The model waits this long to drop it. */export const TRANSITION_MS = 600;export interface Letter { readonly key: string; /** Shrinking out; removed from the model once the transition has had time to finish. */ readonly leaving: boolean;}export interface State { readonly letters: readonly Letter[];}export type Msg = | { readonly _tag: 'Key'; readonly letter: string } | { readonly _tag: 'Gone'; readonly letter: string };const byKey = (a: Letter, b: Letter): number => a.key.localeCompare(b.key);const setLeaving = (s: State, letter: string, leaving: boolean): State => ({ letters: s.letters.map((l) => (l.key === letter ? { ...l, leaving } : l)),});/** Toggles a letter: absent → added, present → leaving (dropped later), leaving → back. */export function toggle(s: State, letter: string): Next<State, Msg> { const existing = s.letters.find((l) => l.key === letter); if (existing === undefined) { return { letters: [...s.letters, { key: letter, leaving: false }].toSorted(byKey) }; } if (existing.leaving) return setLeaving(s, letter, false); return [ setLeaving(s, letter, true), // One lane per letter: toggling it again restarts its own timer, nobody else's. [delay<Msg>(TRANSITION_MS, { _tag: 'Gone', letter }, { key: `leave:${letter}` })], ];}export const AnimatedLetters = define<State, Msg>()('gy-animated-letters', { // The original starts with just "A"; "GYRAL" shows the staggered entrance. init: () => ({ letters: ['A', 'G', 'L', 'R', 'Y'].map((key) => ({ key, leaving: false })) }), // Key presses stream in for as long as the component is in the page (ADR 0025). subscriptions: () => [letterKeys<Msg>((letter) => ({ _tag: 'Key', letter }))], update: { Key: (s, m) => toggle(s, m.letter), // Ignore a stale timer if the letter came back while it was shrinking. Gone: (s, m) => ({ letters: s.letters.filter((l) => l.key !== m.letter || !l.leaving) }), }, view: (s) => html` <p id="hint">Press a letter key (A–Z) to add it, or press it again to remove it.</p> <ul aria-label="Letters" aria-describedby="hint"> ${each( s.letters, (l) => l.key, (l) => html`<li class=${l.leaving ? 'out' : 'in'} aria-hidden=${l.leaving ? 'true' : 'false'}> ${l.key} </li>`, )} </ul> `, styles: css`…`,});
gyral-shop is a department store built with Gyral, Hono and SQLite: catalog and search, accounts, cart, checkout with mock payments, order history, reviews, wish lists and an admin area. Pages are rendered on the server and work without JavaScript; page structure is light DOM and widgets are islands.
Its four themes change only CSS, from a dense marketplace to an airy boutique. See them side by side on the home page.