FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup

Resources

  • Roadmap & changelog
  • GitHub ↗

Getting started

Install the package, import a component from its subpath, and style it with CSS. There is no provider, no theme and no stylesheet to import.

Install

npm install @funstack/ui@alpha

Alpha

@funstack/ui is published as alpha releases under the alpha dist-tag; there is no latest release yet. APIs change without notice.

React 19.3 or later is a peer dependency (react and react-dom >=19.3.0). The library relies on React 19.3 features such as <ViewTransition> and addTransitionType. The package is ESM only and has no other runtime dependencies.

Imports

Every component has its own subpath, which is the documented form. The root entry re-exports everything, and both resolve to the same files.

import { Toggle } from "@funstack/ui/toggle";
import { Tabs } from "@funstack/ui/tabs";
import { Accordion } from "@funstack/ui/accordion";
import { Tooltip } from "@funstack/ui/tooltip";

// Also works:
import { Tabs, Toggle } from "@funstack/ui";

// Shared types live in the root entry.
import type { ActionDetails, ChangeAction } from "@funstack/ui";

Single-element components are plain components (Toggle). Compound components are a namespace of parts (Tabs.Root, Tabs.Tab, ...). The namespace itself is not a component.

A first component

"use client";
import { Toggle } from "@funstack/ui/toggle";
import { saveFavorite } from "./api";

export function FavoriteButton({ postId, initial }: { postId: string; initial: boolean }) {
  return (
    <Toggle
      className="favorite"
      defaultPressed={initial}
      pressedChangeAction={(pressed) => saveFavorite(postId, pressed)}
    >
      Favorite
    </Toggle>
  );
}

pressedChangeAction runs inside a transition. While the returned promise is pending, the button already shows the new pressed state (optimistic) and carries data-pending. If it rejects, the pressed state rolls back and the error goes to the nearest Error Boundary. The Action props page explains the details.

Styling: data attributes and CSS

Components render plain, accessible HTML and ship no styles. Library state appears as data-* attributes, emitted by presence (data-pending="" when true, absent when false). Enum-like states carry a value (data-orientation="vertical").

.favorite[data-pressed] { background: gold; }
.favorite[data-pending] { cursor: progress; opacity: 0.8; }
.favorite[data-disabled] { opacity: 0.5; }

/* Interaction state comes from the platform, not from attributes. */
.favorite:hover { filter: brightness(1.05); }
.favorite:focus-visible { outline: 2px solid; }

Hover, focus and active state are deliberately not attributes: use :hover, :focus-visible and :active. Tailwind's data-* variants work as is: data-pressed:bg-amber-300 data-pending:opacity-80.

When CSS is not enough, className, style and children also accept a function of the part's state:

<Toggle
  className={({ pressed, pending }) => (pending ? "fav busy" : pressed ? "fav on" : "fav")}
>
  {({ pressed, pending }) => (pending ? <Spinner /> : pressed ? <StarFilled /> : <StarOutline />)}
</Toggle>

Replacing the element: render

Every part that renders an element accepts render. The element form replaces the default element and merges the library's props into it; the function form hands you the props and the state.

// Element form: a design-system button. It must accept `ref` and spread its props.
<Toggle render={<MyButton variant="ghost" />}>Bold</Toggle>

// Function form: full control.
<Toggle render={(props, state) => <button {...props}>{state.pressed ? "On" : "Off"}</button>} />

Merge rules for the element form:

  • className values are concatenated; style objects are merged (the element wins). Event handlers the part installs run after the element's own.
  • Attributes the part computes (its role, ARIA state, ids, data-* state) always win. ARIA id lists (aria-describedby, aria-labelledby, aria-controls) are merged instead.
  • Everything else, the element wins. type="button" is only added when the rendered element is a <button>.

What every component shares

PatternMeaning
x / defaultX / xChangeActionControlled value, initial uncontrolled value, and the action called on every change.
data-pendingAn action dispatched through this component is in flight.
transitionForwarded to the part's <ViewTransition default>. Default "none".
renderElement polymorphism (no asChild, no as).
refAn ordinary prop on every part that renders an element.
experimental_*Opt-in use of a platform feature that is not in every engine yet.