Getting started
In this guide you'll create a Gyral app, write a counter component, put it on a page, and test it. You need Node.js 24 or later and a package manager. The examples use npm; pnpm and yarn work the same way.
Create a project
The quickest start is create-gyral. It sets up a Vite project with Gyral, TypeScript and a
first test:
npm create gyral@latest my-app -- --template ssr
cd my-app
npm install
npm run devThere are two templates:
basic: a client-rendered app. The page loads and your components render in the browser. It builds client-only, without hydration code; removeclientOnly: truefromvite.config.tsbefore you render any page on a server.ssr: the same component rendered on the server first, so the page works before any JavaScript loads, then hydrated in place.
Leave out --template and it asks you which one you want.
Or set it up by hand
Gyral works with any bundler. To add it to a Vite project yourself:
npm create vite@latest my-app -- --template vanilla-ts
cd my-app
npm install @gyral/core@gyral/core is the whole runtime: Gyral renders with its own view layer and has no runtime
dependencies.
Then add Gyral's Vite preset. It adds the template compiler to vite build: every template is
checked and precompiled, so a mistake fails the build with a code frame and the bundle doesn't
carry the code that prepares templates at runtime:
// vite.config.ts
import { defineConfig } from 'vite';
import { gyralVitePreset } from '@gyral/core/vite';
export default defineConfig({
...gyralVitePreset(),
});To see the same checks in your editor, add the ESLint config from @gyral/core/eslint (see
Views). Optionally, npm install -D parse5 lets the
compiler check markup against a full HTML parser too.
Write a component
A component is a custom element defined from four parts: its initial state, the intents it understands, how each message changes the state, and how the state looks.
// src/counter.ts
import { define, html } from '@gyral/core';
export interface State {
readonly count: number;
}
export type Msg = { readonly _tag: 'Increment' } | { readonly _tag: 'Decrement' };
export const Counter = define<State, Msg>()('my-counter', {
init: () => ({ count: 0 }),
intent: {
Increment: () => ({ _tag: 'Increment' }),
Decrement: () => ({ _tag: 'Decrement' }),
},
update: {
Increment: (s) => ({ count: s.count + 1 }),
Decrement: (s) => ({ count: s.count - 1 }),
},
view: (s, i) => html`
<button type="button" data-intent=${i.Decrement}>Decrement</button>
<output aria-live="polite">${s.count}</output>
<button type="button" data-intent=${i.Increment}>Increment</button>
`,
});Read it from the bottom up:
- The view is an
htmltemplate. It doesn't attach event handlers. Instead it names an intent on each button withdata-intent.iis typed from your messages, so a misspelti.Incremntis a compile error. - Intent says how a platform event becomes a message. A click on a button carrying
data-intent=${i.Increment}runs theIncrementparser, which returns{ _tag: 'Increment' }. A parser can also read the event's value, form data or key, and returnundefinedto decline it. - Update has one reducer per message, and TypeScript checks you have one for every message. A reducer returns the next state and never changes the old one.
- Init gives the state the component starts with.
Use it in a page
define() registers the element, so importing the module is enough. Replace the contents of
index.html's <body> and import your component:
<body>
<main>
<h1>My first Gyral app</h1>
<my-counter></my-counter>
</main>
<script type="module" src="/src/counter.ts"></script>
</body>Run npm run dev and open the address it prints. The counter is a standard custom element with
its own Shadow DOM, so it works in any page and alongside any framework.
When the page loads an entry module instead (the basic template's src/main.ts), that module
must import every component module, for its side effect. Each new component in its own file
gets a line here, such as the stepper below:
// src/main.ts
import './counter.js';
import './stepper.js';A module that nothing imports never runs, so its tag is never defined: <my-stepper> stays an
empty unknown element. In development, Gyral warns in the console (G0014) when a tag on the page
is still undefined 3 seconds after it loaded; production builds stay silent. A component that renders another
component in its view imports that module too.
Read input
Intents get more than the event. Here's a step size the user can type, read from the input's
value and parsed before it reaches the model:
// src/stepper.ts
import { define, html } from '@gyral/core';
export interface State {
readonly count: number;
readonly step: number;
}
export type Msg =
{ readonly _tag: 'Increment' } | { readonly _tag: 'StepChanged'; readonly step: number };
export const Stepper = define<State, Msg>()('my-stepper', {
init: () => ({ count: 0, step: 1 }),
intent: {
Increment: () => ({ _tag: 'Increment' }),
StepChanged: ({ value }) => {
const step = Number(value);
// Not a whole number: ignore the keystroke, keep the last good step.
return Number.isInteger(step) ? { _tag: 'StepChanged', step } : undefined;
},
},
update: {
Increment: (s) => ({ ...s, count: s.count + s.step }),
StepChanged: (s, m) => ({ ...s, step: m.step }),
},
view: (s, i) => html`
<label for="step">Step</label>
<input id="step" type="number" value=${s.step} data-intent=${i.StepChanged} />
<button type="button" data-intent=${i.Increment}>Add ${s.step}</button>
<output aria-live="polite">${s.count}</output>
`,
});The model only ever sees a whole number. Parsing happens once, at the boundary, which is where Gyral wants it.
Test it
update is a plain function, so you can test behaviour without a browser. @gyral/testing
gives you step (one message) and run (a sequence):
npm install -D @gyral/testing vitest// src/counter.test.ts
import { expect, it } from 'vitest';
import { run, step } from '@gyral/testing';
import { Counter } from './counter.js';
it('counts', () => {
expect(step(Counter.spec, { count: 1 }, { _tag: 'Increment' }).state).toEqual({ count: 2 });
const { state } = run(Counter.spec, [
{ _tag: 'Increment' },
{ _tag: 'Increment' },
{ _tag: 'Decrement' },
]);
expect(state.count).toBe(1);
});Counter.spec is the object you passed to define(). Tests that need the real element (clicks,
focus, rendering) run in a browser with Vitest browser mode
and await settled() before they look at the DOM; @gyral/testing also has helpers for server
rendering, hydration and virtual time. See Testing.
Next steps
- Build something real from a recipe: a
todo list with filters and a
signup form with validation, each a
complete component with tests that needs nothing beyond a
basicapp. - Learn the three parts in depth: Intent, Model and update and Views.
- Browse the examples: forms, HTTP, routing, shared state and server rendering.
- See a whole application in gyral-shop.
- Before you ship, read Deploying and the known issues.
- Working with a coding agent? Connect it to Gyral's MCP server and skill: Use Gyral with coding agents.
- Upgrading an existing app? Versioning and stability explains how to move to 0.3.6 safely.