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.
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.
One native <input type="checkbox">, three appearance values. The switch adds role="switch" to the same input.
Every tab is a native <input type="radio">carrying role="tab". The browser keeps one selected and moves it with the arrow keys.
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.
Native elements come with accessibility, keyboard support and form integration that are easy to get wrong by hand.
A native <dialog> opened with showModal(): focus trap, Escape and top-layer rendering come from the browser.
---
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)}><input type="checkbox"></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)}><input type="radio"></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)}><button aria-expanded></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)}><dialog></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>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.
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.
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:
<summary> can’t host buttons or other interactive content, and rich triggers (header actions, badges, inputs) need exactly that.<details> content makes hard.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.
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.core-dialog sets overflow: hidden on body while a dialog that locks scroll is open, with a counter so nested dialogs release it correctly.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.
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>
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.
<button>, <input>, <select>, <dialog>, or the popover attribute do what you need? Use it:checked, :focus-visible, :disabled, [open], [hidden], and ::backdrop replace most UI state logicrole to elements that already have it — a <button> doesn’t need role="button"e.preventDefault() on a native element, consider whether you’re fighting the platform<form> for data collection — forms give you validation, submission, and reset for free