guideline

HTML-First Components

Pindoba follows native HTML structure and behavior, using semantic elements and CSS instead of reimplementing browser features with JavaScript.

Pindoba follows a simple rule: use the HTML element that already does what you need. We don’t overwrite default behavior, we don’t reimplement browser features with JavaScript, and we don’t fight the platform.

The Principle

Browsers ship with interactive elements that handle keyboard navigation, focus management, form submission, and accessibility announcements. Reimplementing these with <div onClick> or custom JavaScript is error-prone, less accessible, and more code to maintain.

Native Elements in Action

Checkbox: checkbox, switch, button

One native <input type="checkbox">, three appearance values. The switch adds role="switch" to the same input.

Tabs on radio inputs

Every tab is a native <input type="radio">carrying role="tab". The browser keeps one selected and moves it with the arrow keys.

Tab content goes here.

Disclosure: button + hidden=until-found

A real <button aria-expanded>trigger and a role="region" panel. Browser find-in-page still opens a closed panel.

Using native elements and their built-in behavior before reaching for JavaScript.

Dialog

A native <dialog> opened with showModal(): focus trap, Escape and top-layer rendering come from the browser.

Native dialog

Focus stays inside until you close it with Escape or the close button.
---
import { css } from "@pindoba/styled-system/css";
import Accordion from "@pindoba/astro-accordion";
import Button from "@pindoba/astro-button";
import Checkbox from "@pindoba/astro-checkbox";
import Dialog from "@pindoba/astro-dialog";
import Radio from "@pindoba/astro-radio";
import { Tab } from "@pindoba/astro-tab";

const containerStyle = css({
  display: "flex",
  flexDirection: "column",
  gap: "lg",
});

// A framed section recesses into the peak reading sheet.
const sectionStyle = css.raw({
  backgroundColor: "neutral.surface.base",
  borderRadius: "lg",
  padding: "lg",
  border: "1px solid",
  borderColor: "neutral.border.muted",
});

const sectionTitleStyle = css.raw({
  fontSize: "md",
  fontWeight: "bold",
  marginBottom: "sm",
  color: "neutral.text.bold",
});

const descriptionStyle = css.raw({
  fontSize: "sm",
  color: "neutral.text",
  marginBottom: "md",
});

const codeStyle = css.raw({
  fontFamily: "mono",
  fontSize: "xs",
  // Two steps below the base section it sits in.
  backgroundColor: "neutral.surface.ground",
  padding: "2xs xs",
  borderRadius: "sm",
  color: "neutral.text.bold",
});

const rowStyle = css.raw({
  display: "flex",
  gap: "md",
  flexWrap: "wrap",
  alignItems: "center",
});
---

<div class={containerStyle}>
  <div class={css(sectionStyle)}>
    <h3 class={css(sectionTitleStyle)}>Checkbox: checkbox, switch, button</h3>
    <p class={css(descriptionStyle)}>
      One native{" "}
      <code class={css(codeStyle)}>&lt;input type="checkbox"&gt;</code>, three{" "}
      <code class={css(codeStyle)}>appearance</code> values. The switch adds{" "}
      <code class={css(codeStyle)}>role="switch"</code> to the same input.
    </p>
    <div class={css({ display: "flex", flexDirection: "column", gap: "md" })}>
      <div class={css(rowStyle)}>
        <Checkbox id="hf-check-a" name="hf-checkbox" checked>
          Option A
        </Checkbox>
        <Checkbox id="hf-check-b" name="hf-checkbox">
          Option B
        </Checkbox>
        <Checkbox id="hf-switch" appearance="switch" checked>
          Wi-Fi
        </Checkbox>
        <Checkbox id="hf-button" appearance="button">
          Bold
        </Checkbox>
      </div>
      <div class={css(rowStyle)}>
        <Radio id="hf-choice-1" name="hf-radio" checked>
          Choice 1
        </Radio>
        <Radio id="hf-choice-2" name="hf-radio">
          Choice 2
        </Radio>
        <Radio id="hf-choice-3" name="hf-radio">
          Choice 3
        </Radio>
      </div>
    </div>
  </div>

  <div class={css(sectionStyle)}>
    <h3 class={css(sectionTitleStyle)}>Tabs on radio inputs</h3>
    <p class={css(descriptionStyle)}>
      Every tab is a native{" "}
      <code class={css(codeStyle)}>&lt;input type="radio"&gt;</code>
      carrying <code class={css(codeStyle)}>role="tab"</code>. The browser keeps
      one selected and moves it with the arrow keys.
    </p>
    <Tab
      items={[
        { id: "hf-tab1", label: "Tab 1" },
        { id: "hf-tab2", label: "Tab 2" },
        { id: "hf-tab3", label: "Tab 3" },
      ]}
      defaultTab="hf-tab1"
    >
      <div
        slot="hf-tab1"
        class={css({ fontSize: "sm", color: "neutral.text" })}
      >
        Tab content goes here.
      </div>
      <div
        slot="hf-tab2"
        class={css({ fontSize: "sm", color: "neutral.text" })}
      >
        Tab 2 content.
      </div>
      <div
        slot="hf-tab3"
        class={css({ fontSize: "sm", color: "neutral.text" })}
      >
        Tab 3 content.
      </div>
    </Tab>
  </div>

  <div class={css(sectionStyle)}>
    <h3 class={css(sectionTitleStyle)}>
      Disclosure: button + hidden=until-found
    </h3>
    <p class={css(descriptionStyle)}>
      A real <code class={css(codeStyle)}>&lt;button aria-expanded&gt;</code>
      trigger and a <code class={css(codeStyle)}>role="region"</code> panel.
      Browser find-in-page still opens a closed panel.
    </p>
    <Accordion
      type="single"
      collapsible
      items={[
        { value: "hf-what", title: "What is HTML-first?", open: true },
        { value: "hf-why", title: "Why avoid reimplementing native elements?" },
      ]}
    >
      <p slot="hf-what">
        Using native elements and their built-in behavior before reaching for
        JavaScript.
      </p>
      <p slot="hf-why">
        Native elements come with accessibility, keyboard support and form
        integration that are easy to get wrong by hand.
      </p>
    </Accordion>
  </div>

  <div class={css(sectionStyle)}>
    <h3 class={css(sectionTitleStyle)}>Dialog</h3>
    <p class={css(descriptionStyle)}>
      A native <code class={css(codeStyle)}>&lt;dialog&gt;</code> opened with{" "}
      <code class={css(codeStyle)}>showModal()</code>: focus trap, Escape and
      top-layer rendering come from the browser.
    </p>
    <Dialog id="hf-dialog" title="Native dialog">
      Focus stays inside until you close it with Escape or the close button.
    </Dialog>
    <Button id="hf-dialog-trigger">Open dialog</Button>
  </div>
</div>

<script>
  document
    .getElementById("hf-dialog-trigger")
    ?.addEventListener("click", () => {
      window.__PindobaDialogManager?.open("hf-dialog");
    });
</script>

Checkboxes as Toggle Buttons

HTML checkboxes already behave like toggle buttons: they have an on/off state, respond to click and Space, participate in forms, and announce their state. Pindoba’s Checkbox keeps the native <input type="checkbox"> and changes only how it looks, through appearance:

  • "checkbox" (default) is the plain box.
  • "switch" adds role="switch" to the same input, so screen readers announce on/off instead of checked/unchecked.
  • "button" renders the label as a toggle button.
<Checkbox name="notifications" checked>
  Enable notifications
</Checkbox>
<Checkbox appearance="switch">Wi-Fi</Checkbox>
<Checkbox appearance="button">Bold</Checkbox>

What you get for free: Space toggles, form submission includes the value, :checked drives styling. No state management code.

Radio Inputs for Tabs

A tab strip is a set of mutually exclusive options, which is what radio inputs are. Each tab in Pindoba is a native <input type="radio"> that carries role="tab" itself, wrapped by its <label>. The tablist role sits on the inner scroll container and the surrounding <nav> is role="none":

<nav role="none">
  <div role="tablist">
    <label>
      <input
        type="radio"
        name="tabs"
        role="tab"
        aria-selected="true"
        aria-controls="panel-general"
        checked
      />
      General
    </label>
    <!-- more tabs -->
  </div>
</nav>

What you get for free: one tab selected at a time, Tab enters on the checked tab, arrow keys move focus and selection, and the value takes part in forms. The selection state is the radio group’s checked, so there is no store to keep in sync. JavaScript is limited to the keyboard and panel-visibility glue in tab-nav-dom.ts in @pindoba/core-tab.

Disclosure: a button + hidden=until-found

Pindoba’s Accordion is not built on <details>. An item is a plain <div>, the trigger is a <button type="button" aria-expanded>, and the panel is a <div role="region" hidden="until-found">:

<div data-state="closed">
  <button type="button" aria-expanded="false" aria-controls="panel-a">
    Advanced settings
  </button>
  <div role="region" id="panel-a" hidden="until-found">Content</div>
</div>

We chose this over <summary> for three reasons:

  • Real button semantics. A <summary> can’t host buttons or other interactive content, and rich triggers (header actions, badges, inputs) need exactly that.
  • Animatable. The panel’s open and close can be animated, which <details> content makes hard.
  • Find-in-page still works. hidden="until-found" keeps the content searchable. When the browser finds a match inside it, the beforematch event fires and the accordion opens the panel.

The platform still does the heavy lifting (focus, Enter/Space activation, hidden content semantics). JavaScript only toggles aria-expanded and the hidden state.

Dialog for Modals

Pindoba uses the native <dialog> element, and the open mode decides what the browser gives you:

  • showModal() (the default) renders the dialog in the top layer with a native focus trap, Escape to close, an inert page behind it, and ::backdrop.
  • show() (non-modal) gives none of that: no trap, no Escape, no inert background. Use it for non-blocking panels.
  • Scroll lock is not native. core-dialog sets overflow: hidden on body while a dialog that locks scroll is open, with a counter so nested dialogs release it correctly.
  • A dialog marked data-popover is the popover path: it stays out of the modal stack and handles its own outside-click dismissal, which is how popovers reuse the same <dialog> element.
<Dialog id="settings" title="Settings">
  Content
</Dialog>
<Button id="open-settings">Open</Button>

<script>
  document.getElementById("open-settings")?.addEventListener("click", () => {
    window.__PindobaDialogManager?.open("settings");
  });
</script>

Inside, <form method="dialog"> closes the dialog on submit with no handler.

Default Props Are Not Overwritten

Pindoba components spread user props onto the underlying HTML element. All native attributes pass through:

<!-- All native attributes work as expected -->
<Button type="submit" form="my-form" disabled>
  Submit
</Button>
<Button type="button" popovertarget="my-popover">
  Toggle
</Button>

When JavaScript Is Necessary

Some interactions genuinely require JavaScript: dynamic positioning (popovers), scroll locking (dialogs), the beforematch wiring for disclosure, and complex drag-and-drop. In those cases, Pindoba’s core packages provide the minimal JavaScript needed while still rendering native HTML elements.

Best Practices

  1. Start with the native element — can <button>, <input>, <select>, <dialog>, or the popover attribute do what you need? Use it
  2. Style with CSS, not JavaScript — :checked, :focus-visible, :disabled, [open], [hidden], and ::backdrop replace most UI state logic
  3. Don’t add role to elements that already have it — a <button> doesn’t need role="button"
  4. Don’t prevent default behavior — if you’re calling e.preventDefault() on a native element, consider whether you’re fighting the platform
  5. Use <form> for data collection — forms give you validation, submission, and reset for free