Morphos
Guide

Introduction

What is Morphos and why does it exist?

Philosophy

Morphos (μορφή, Greek: form, shape). Not the look of a thing — the structure underneath it.

A dialog knows how to open, trap focus, and close on Escape. It does not know whether it is centered, blurred, or bordered. A checkbox knows whether it is checked. It does not know what "checked" looks like. Morphos provides the form — the behavioral skeleton. You provide the shape.

This distinction shows up in how state is exposed. A component doesn't toggle a .is-open class that you have to know about and override. It sets data-open on the root element — a fact about what the component is right now, not a styling instruction. Your CSS reacts to that fact however it wants.

The practical consequence: when something doesn't look the way you expected, open DevTools and check the element. Either data-open is there or it isn't. Either data-disabled is set or it isn't. The state is visible — not buried in a class name, not encoded in a variant prop, not hidden inside the component.

What is Morphos?

Morphos is a headless primitive component library for PraxisJS. It provides accessible, behavior-complete UI building blocks without any built-in styles.

Think of it as the PraxisJS equivalent of Base UI or Radix UI — the logic layer that sits between your design system and the browser.

What Morphos provides

  • Behavior — open/close state, controlled/uncontrolled APIs, keyboard navigation, roving tabindex where it matters.
  • Accessibility — ARIA roles and attributes, focus management, focus trapping, and scroll locking, all wired up by default.
  • State exposuredata-* attributes on every component root, enabling pure CSS styling with no class toggling.
  • PraxisJS integration — class components + decorators, lifecycle hooks, and fine-grained reactivity, native to how PraxisJS works.

What Morphos does not provide

  • CSS or visual styles of any kind, by default.
  • Animation or transition logic (bring your own, or use @praxisjs/motion).
  • Layout or positioning beyond what's needed for overlays (components render where you place them; Portal is used to escape stacking contexts for floating UI).

Don't want to write every CSS rule from scratch? The separate @morphos/styles package ships an optional, opt-in recipe for every component — nothing is applied unless you import it yourself.

How components are organized

Morphos is split into five packages so you only install what you actually use:

PackageWhat's in it
@morphos/coreShared utilities every other package depends on: generateId, accessibility helpers (trapFocus, lockScroll, isActivationKey, wrapIndex, Keys), computeAnchorPosition for floating UI, and shared types (PrimitiveProps, Orientation, Size).
@morphos/inputsButton, Input, Checkbox, CheckboxGroup, RadioGroup + Radio, Select, Switch, Toggle, ToggleGroup, Slider, NumberField, OtpField, Combobox, Autocomplete, Field, Fieldset, Form.
@morphos/overlaysDialog, AlertDialog, Drawer, Popover, Tooltip, Dropdown (aliased Menu), ContextMenu, PreviewCard.
@morphos/layoutAccordion, Tabs, Disclosure (aliased Collapsible), Separator, ScrollArea, Toolbar, Menubar, NavigationMenu.
@morphos/feedbackToastProvider + Toast, Alert, Progress, Spinner, Avatar, Meter.
@morphos/styles (optional)Plain CSS recipes, one file per component. Nothing is imported by default — see Styling.

@morphos/core is a required peer of every category package — install it alongside whichever categories you need. The full walkthrough is in Getting Started.

The compound-component pattern

Every component with more than one moving part (a Dialog, a RadioGroup, an Accordion) is split into several classes: one root that owns the state, and several parts that read and drive it. The root instance is created once and passed down to its parts as a prop named after the component — dialog={this.dialog}, accordion={this.accordion}, group={this.group}.

@Component()
class MyPage extends StatefulComponent {
  @State() dialog = new Dialog()

  render() {
    return (
      <>
        <DialogTrigger dialog={this.dialog}>Open</DialogTrigger>
        <DialogContent dialog={this.dialog}>...</DialogContent>
      </>
    )
  }
}

There's no React-style Context here — the root instance is the shared state, passed around like any other prop. It keeps things explicit and easy to trace: if you want to know what a part reacts to, look at what was passed into it.

The data-* convention

Every interactive component state is surfaced as a data-* attribute on its root element. Values are either "" (present, no value — a boolean flag) or a string (an enum-like state).

AttributeSet on
data-openCombobox, Autocomplete, Select, Disclosure/Collapsible, Dialog, AlertDialog, Drawer, Popover, Dropdown/Menu, ContextMenu, PreviewCard, Tooltip
data-disabledButton, Input, Checkbox, CheckboxGroup(Item), Select, RadioGroup/Radio, Switch, Toggle(Group), Slider, NumberField, OtpField, Combobox, Autocomplete, Field, Fieldset, Accordion, Tabs, Toolbar, Menubar, Dropdown, ContextMenu
data-checkedCheckbox, CheckboxGroupItem, Radio, Switch
data-indeterminateCheckbox, Progress
data-selectedCombobox / Select (active option), Tabs (Tab / TabPanel)
data-activeCombobox / Autocomplete / Select (keyboard-focused option), NavigationMenuItem
data-expandedAccordionItem, AccordionTrigger, AccordionContent
data-orientationCheckboxGroup, RadioGroup, Slider, ToggleGroup, NavigationMenu, ScrollArea, Separator, Tabs, Toolbar
data-typeToggleGroup (single/multiple), Accordion (single/multiple), ScrollArea (auto/always/scroll/hover/hidden)
data-variantAlert, Toast
data-valueMeter, Progress, Slider
data-sideDrawer (top/right/bottom/left)
data-statusAvatar (image load status)
data-scrollableScrollArea
data-focusedInput
data-invalidField, Input
data-requiredField
data-placeholderSelect
data-pressedToggle, ToggleGroupItem
data-indexCombobox (option index), OtpField (cell index)
data-high / data-low / data-optimumMeter

This allows pure CSS styling without any class manipulation:

[data-disabled] { opacity: 0.5; cursor: not-allowed; }
[role="tab"][data-selected] { border-bottom: 2px solid currentColor; }
[role="dialog"][data-open] { display: block; }

Ready to try it? Head to Getting Started for the full install + first-component walkthrough.

On this page