Skip to contentAsheeUIAsheeUI

First component

The smallest real screen, composed from components, a page shell, one field and one action, and what each part states on its own.

Installation ends with a provider and one Button on the page. The next thing most people write is a div with a class name, and this page exists to break that habit: a screen is composed from components, and every one of them states something the markup would otherwise have to remember, a landmark, an accessible name, an error announcement, a submit control that cannot be pressed twice.

The screen below invites a teammate by email. Three steps, about forty lines, one file.

1. Start from the shell

import { Page, PageContent, PageHeader, Typography } from "@asheeui/web";

<Page>
  <PageHeader>
    <Typography role="heading-xl">Invite a teammate</Typography>
  </PageHeader>
  <PageContent>{/* the form goes here */}</PageContent>
</Page>;

Four components and no class names. Page claims the height of the window and paints the theme background; PageHeader is the header landmark and holds the bar at the top; PageContent is the main landmark and takes the height the other two leave. Typography renders the element its role implies, heading-xl is an h1, so the one heading on the screen is a real level-one heading without anyone stating it.

The shell adds no navigation, no data and no breakpoint. Each option belongs to the part that can act on it, which is why <PageContent spacing="lg"> moves the content under the bar and <Page sticky> does not compile at all, a shell has no bar to keep in view. Page has that split in full.

2. Put one field in it

import { Form, Input } from "@asheeui/web";
import { useState } from "react";

const [email, setEmail] = useState("");
const [message, setMessage] = useState<string>();

<Form legend="Who should join?" submitLabel="Send invite" onSubmit={submit}>
  <Input
    label="Email"
    name="email"
    type="email"
    description="We send the invite here."
    value={email}
    onChangeText={setEmail}
    status={message ? "error" : "default"}
    message={message}
    required
  />
</Form>;

Input is one component wearing the field family's four parts: label is the accessible name, description is the hint read with the control, and message with status is the outcome. Because they are props rather than sibling elements, the component wires the ids itself, htmlFor for the label, aria-describedby for the hint and the message, aria-invalid for the status, so a field cannot ship with a message that nothing announces. Field anatomy is that shape in full.

The value stays on the page, and the field reports every change through onChangeText. That is what lets the page validate, reset or submit without reaching into the DOM.

3. Give it an action

Form draws the action itself: submitLabel renders a submit Button with type="submit" inside the form's element, so Enter in the field submits it. The page supplies the rule and the consequence.

import { type FormEvent, useState } from "react";
import {
  Form,
  Input,
  Page,
  PageContent,
  PageHeader,
  Typography,
} from "@asheeui/web";

export function InviteTeammate() {
  const [email, setEmail] = useState("");
  const [message, setMessage] = useState<string>();
  const [invited, setInvited] = useState<string>();

  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    if (!email.includes("@")) {
      setMessage("An email address needs an @.");
      return;
    }

    setMessage(undefined);
    setInvited(email);
  }

  return (
    <Page>
      <PageHeader>
        <Typography role="heading-xl">Invite a teammate</Typography>
      </PageHeader>
      <PageContent spacing="lg">
        <Form
          legend="Who should join?"
          submitLabel="Send invite"
          onSubmit={submit}>
          <Input
            label="Email"
            name="email"
            type="email"
            description="We send the invite here."
            value={email}
            onChangeText={setEmail}
            status={message ? "error" : "default"}
            message={message}
            required
          />
        </Form>
        {invited && (
          <Typography role="body-sm" tone="muted">
            {invited} now has an invite.
          </Typography>
        )}
      </PageContent>
    </Page>
  );
}

This screen leaves one state out: while the request is in flight, pass isPending to Form and the submit control is busy and cannot be pressed twice, with its label still in place so the row does not move. Building a form covers that, and the focus rule below.

The component states the meaning, the page states the content

That sentence is the whole approach, and this screen is one example of each half.

You wrote It stated Instead of
Page, PageHeader, PageContent The shell, its height, and the header and main landmarks A div with min-h-dvh and a main placed by hand
Typography role="heading-xl" A level-one heading, and the treatment that role carries An h1 with a size and a weight chosen per screen
Form legend submitLabel The fieldset and its legend, the submit control, and its pending state when you pass isPending A form, a fieldset, a legend and a button
Input label description message status The accessible name, the hint, the message, aria-invalid, aria-describedby A label, an id on a paragraph and aria-describedby typed by hand
PageContent A gutter and a maximum width mx-auto max-w-5xl px-4 sm:px-6

The other half is yours, and it is smaller than the markup it replaces: the values, the words on the label and the description, the sentence that explains the failure, the rule that decides it, and what happens when it passes. None of that is styling, so none of it is a class name.

What it reads like to a screen reader

The reader arrives on this screen and hears, in this order:

  • A banner landmark, the PageHeader, and inside it "Invite a teammate, heading level 1".
  • A main landmark, PageContent, which is also what a skip link points at.
  • "Who should join?, group", because the form's fields are grouped by the fieldset its legend names.
  • "Email, edit, required". The label is the name; the requirement is on the control, and the asterisk next to the label is only drawn.
  • "We send the invite here.", read with the field through aria-describedby rather than drifting after it as a stray paragraph.
  • On a failed submit, the message, announced immediately, without waiting for a pause, because the field's message element carries role="alert", and the field reporting itself invalid, so it is invalid to assistive technology and not merely red.
  • A submit button named "Send invite", which Enter in the field presses.

That is the framework's half. The one thing it cannot do for you: after a failed submit it does not know which field to fix first, so the page moves focus to the first invalid control. Building a form has the effect that does it. For how the parts compose and what they announce, see Accessibility and Announcements.

What to reach for next

  • A second field, validation and a pending submit: Building a form.
  • A record count, a collection or a filter: Components has the gallery, and the table family starts at Table.
  • A shell with navigation, a sidebar or a marketing band: Page compositions.
  • Stating the spacing, the radius and the variants once for the whole product: Configuration, then Theming.