Your First Page - Step 5

Build your first Thorm page and learn the basic workflow: structure, state, and rendering mode.

The Libraries: Contact

/Components/Contact.php This component builds the main contact section with two functional areas:

  • Static business contact info: it defines a contactInfo atom array (phone, email, HQ address, hours) and renders it client‑side using (repeat(...). This produces a consistent list without hardcoding each entry in markup. It also shows a static HQ map image. Using an atom here, showcase how to make the content dynamic, as the atom can change state (updating its content from an API endpoint) which will automatically change the content in page.
  • Dynamic contact form: it renders a form that captures full name, email, subject, and message using Thorm atoms for live input state. On submit, it prevents the browser’s default form action and sends a POST request to(/tutorials/api/ with the form data in application/x-www-form-urlencoded format. The response is stored in $response, and the UI flips to a “Plan received” view that displays the server message. After 10 seconds, it resets and shows the form again.
    • Switching between the form contact and server response is done through a bit of reactivity, we store the state of the form submission on $messageSent then we update its value on the effect onSelf() actions. Changing the atom value will trigger the show(eq(read($messageSent), true/false), ...) to show the right component.

So functionally, it’s the page’s core: a static, runtime‑built “contact methods” column, and a dynamic form that submits data asynchronously and shows the server’s reply without a full page reload.

PHP
<?php declare(strict_types=1); namespace App\Components; use Thorm\IR\Atom; use Thorm\IR\Node\Node; use function Thorm\attrs; use function Thorm\button; use function Thorm\cls; use function Thorm\concat; use function Thorm\cond; use function Thorm\delay; use function Thorm\div; use function Thorm\eq; use function Thorm\ev; use function Thorm\form; use function Thorm\get; use function Thorm\h2; use function Thorm\http; use function Thorm\img; use function Thorm\input; use function Thorm\item; use function Thorm\label; use function Thorm\on; use function Thorm\onSelf; use function Thorm\read; use function Thorm\repeat; use function Thorm\selectorTarget; use function Thorm\set; use function Thorm\show; use function Thorm\span; use function Thorm\state; use function Thorm\text; use function Thorm\textarea; use function Thorm\val; final class Contact { public static function get(): Node { // Atoms $messageSent = state(false); $response = state(null); // Atom holding contact info, will use this bellow in repeat() $contactInfo = state([ ['id' => 1, 'color' => 'orange', 'name' => 'Phone', 'value' => '+1 (555) 777-ACME'], ['id' => 2, 'color' => 'yellow', 'name' => 'Email', 'value' => 'acme@thorm.dev'], ['id' => 3, 'color' => 'blue', 'name' => 'HQ', 'value' => 'Desert Road 1337, Canyon City'], ['id' => 4, 'color' => 'red', 'name' => 'Hours', 'value' => 'Mon - Fri: 9 AM - 6 PM'], ]); // template for repeat() $template = div([cls('flex items-start gap-3')], [ span([cls(concat('mt-1 h-3 w-3 rounded-full bg-acme-', item('color')))]), div([], [ div([cls('text-acme-ink')], [ text(item('name')) ]), div([], [ text(item('value')) ]), ]), ]); return Section::get([ div([ cls('card px-6 py-6 md:px-8'), ], [ h2([cls('title-font text-2xl')], [text('ACME HQ hotline')]), div([cls('mt-5 space-y-4 text-base font-semibold text-acme-muted'),],[ // build the contact info at runtime in browser repeat( read($contactInfo), item('id'), $template ), img([ cls('rounded-2xl border-2 border-acme-ink shadow-acme'), attrs(['src' => '/images/acme-hq-map_256x256.webp', 'alt' => 'ACME HQ map']) ]), ]), ]), div([ cls('card px-6 py-6 md:px-8'), ], [ h2([cls('title-font text-2xl')], [ text(cond(read($messageSent), 'Plan received', 'Send the plan')) ]), show(eq(read($messageSent), false), self::_ContactForm($response, $messageSent)), show(eq(read($messageSent), true), self::_Response($response)), ]), ], [ 'style' => 'grid gap-6 md:grid-cols-[1fr_1.2fr]', ]); } private static function _ContactForm(Atom $response, Atom $messageSent): Node { // atoms $fullname = state(''); $email = state(''); $subject = state(''); $message = state(null); return form([ cls('mt-5 space-y-4 text-sm font-semibold text-acme-muted'), attrs(['id' => 'frm-contact']), ],[ div([cls('grid gap-4 md:grid-cols-2')], [ label([cls('space-y-2')],[ text('Full Name'), input([ cls('input-acme w-full rounded-2xl px-4 py-3 text-sm'), attrs([ 'type' => 'text', 'placeholder' => 'Billy Bob Thornson', ]), on('input', set($fullname, ev('target.value'))), ]), ]), label([cls('space-y-2')],[ text('Email'), input([ cls('input-acme w-full rounded-2xl px-4 py-3 text-sm'), attrs([ 'type' => 'email', 'placeholder' => 'billybob@acme.com', ]), on('input', set($email, ev('target.value'))), ]), ]), ]), label([cls('space-y-2 block')],[ text('Subject'), input([ cls('input-acme w-full rounded-2xl px-4 py-3 text-sm'), attrs([ 'type' => 'text', 'placeholder' => 'Rocket skates request', ]), on('input', set($subject, ev('target.value'))), ]), ]), label([cls('space-y-2 block')],[ text('Message'), textarea([ cls('input-acme w-full rounded-2xl px-4 py-3 text-sm'), attrs([ 'rows' => '6', 'placeholder' => 'We need a plan that ends with confetti.', ]), on('input', set($message, ev('target.value'))), ]), ]), div([cls('flex flex-wrap gap-3')], [ button([ cls('rounded-full border-2 border-acme-ink bg-acme-orange px-6 py-3 text-sm font-bold text-white shadow-acme transition hover:-translate-y-0.5'), attrs([ 'type' => 'submit', ]), ], [ text('Send the plan'), ]), button([ cls('rounded-full border-2 border-acme-ink bg-acme-cream px-6 py-3 text-sm font-bold shadow-acme transition hover:-translate-y-0.5'), attrs([ 'type' => 'button', ]), ], [ text('Add fireworks') ]), ]), // EFFECT: bind to this form's submit (use target selector) onSelf('submit', [ // POST body (simple x-www-form-urlencoded example) http( val('/tutorials/api/'), // url 'POST', // method $response, // to null, // response status [ 'Content-Type' => 'application/x-www-form-urlencoded' ], // request headers concat( // body 'fullname=', read($fullname), '&email=', read($email), '&subject=', read($subject), '&message=', read($message) ), 'json', // true, // asAction null // response headers ), // after posting set($messageSent, true, true), delay(10000, [set($messageSent, false, true)]) ], [ // options array for effect 'passive' => false, // if true will ignore preventDefault 'preventDefault' => true, // stops form to submit and refresh the page 'stopPropagation' => true // stops event propagation ], selectorTarget('#frm-contact')) // we select form id by '#frm', it is a JavaScript document.querySelect() requirement ]); } private static function _Response(Atom $response): Node { $status = state('error'); return div([ cls('rounded-2xl border-2 border-acme-ink bg-acme-yellow px-4 py-3 text-lg font-bold text-acme-ink shadow-acme') ],[ text(get(read($response), 'message')), ]); } }
Status: Developer Preview
Things may change, things might break. If something feels awkward, it's probably a design edge we're still smoothing out.