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 plugins, gyral(). They add 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 { gyral } from '@gyral/core/vite';
export default defineConfig({
plugins: [gyral()],
});List plugins of your own beside it: plugins: [gyral(), mine()].
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. on('input', parser)
gives the parser the number input's valueAsNumber (undefined while the field is empty or
invalid), and the parser checks it before it reaches the model:
// src/stepper.ts
import { define, html, on } 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' }),
// Not a whole number: ignore the keystroke, keep the last good step.
StepChanged: on('input', ({ valueAsNumber: step }) =>
step !== undefined && 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.9 safely.