React components, built to one contract.
4AF is a personal library of 47 React components built on Base UI. They share theme tokens, motion conventions and keyboard interaction patterns.
Using a coding agent? Point it at the agent skill.
Components
The contract
The library follows these conventions.
- Behaviour from Base UI
- Base UI supplies focus, dismissal and keyboard navigation. Calendar uses react-day-picker.
- State on the element
- data-* attributes expose what a component is doing, so styles and tests read the DOM rather than props.
- Prefer compositor-friendly motion
- Animations favor transform, opacity, filter and clip-path. Collapsible and Sidebar have documented layout-motion exceptions.
- Durations from tokens
- CSS transitions use duration tokens; JavaScript animations respect reduced motion separately.
- Controlled or uncontrolled
- Interactive components support controlled and uncontrolled state through their value, checked, pressed or open APIs.
- Lists open ready
- Selects, menus and the command palette open with a row already highlighted, so Enter works straight away.
Install
Install any component with the shadcn CLI. The theme, the shared utilities, and the components it builds on come with it.
npx shadcn@latest add https://4af.selfsimilar.dev/r/button.jsonInstalling the button writes these files:
"use client";
import { Button as BaseButton } from "@base-ui/react/button";
import { motion, useReducedMotion, type HTMLMotionProps } from "motion/react";
import {
Children,
cloneElement,
Fragment,
isValidElement,
type ComponentProps,
type MouseEvent,
type ReactElement,
type ReactNode,
type Ref,
} from "react";
import { cn } from "@/lib/cn";
import { PRESS_SCALE, spring } from "@/lib/motion";
import { LoadingIndicator } from "@/lib/loader";
import { useSlotChild } from "@/lib/slot";
import { buttonVariants, type ButtonVariants } from "./button.variants";
/**
* These collide with Motion's own props of the same names, which are gesture and
* animation callbacks rather than DOM events. Dropping them from the public type is
* the standard cost of putting a motion component behind a native-looking API.
*/
export interface ButtonProps
extends Omit<
ComponentProps<typeof BaseButton>,
"children" | "className" | "nativeButton" | "render"
>,
ButtonVariants {
className?: string;
/** Render the styles onto the child element instead of a <button>. */
asChild?: boolean;
/** Swaps in a spinner and blocks interaction, without changing the button's size. */
loading?: boolean;
/** Overrides the nearest LoaderProvider for this button's loading indicator. */
loader?: ReactNode;
/** Animate a scale change while pressed. Set false for anchored triggers. @default true */
pressScale?: boolean;
/**
* Holds the button in its pressed state — e.g. while a popover or menu it triggers is
* open, so the trigger reads as "held down" for as long as its surface is showing. It's
* the same spring as the tap press, and it *replaces* the tap press while held (rather
* than layering on top), so pressing an already-held button to dismiss doesn't compound
* the scale or fight a second timing curve on the way back up.
*/
pressed?: boolean;
/**
* What the press scale applies to.
*
* `"button"` (the default) presses the whole control. `"content"` presses only the
* label and icons and holds the box still — the version a button needs when its
* edges are joined to its neighbours', because shrinking the box there opens a
* visible gap along every seam. `ButtonGroup` sets it; on its own a button has
* nothing to avoid and should press as one object.
*/
pressTarget?: "button" | "content";
/** Announced while `loading`, since the visible label is hidden from sight. */
loadingLabel?: string;
children?: ReactNode;
ref?: Ref<HTMLElement>;
}
// Created once at module scope: doing it inside the component would mint a new
// component type on every render and remount the child.
const MotionButton = motion.button;
const MotionSpan = motion.span;
/**
* The press, as *variants* rather than as values.
*
* A label is the only thing Motion can't reach directly here: `whileTap` listens on
* the element it's attached to, and an inner span only covers the text — press the
* padding and nothing would happen. So the gesture stays on the button and hands the
* state down by name; Motion propagates a variant label to any child that declares
* one, which is how the box can own the gesture while the content owns the movement.
*/
const PRESS_VARIANTS = {
rest: { scale: 1 },
pressed: { scale: PRESS_SCALE },
};
/**
* The same content press for the `asChild` path, which can't use Motion — wrapping an
* arbitrary child would mean nesting DOM that isn't valid. Same 0.95, driven off the
* element's `:active` and the fast duration token, so it collapses under reduced
* motion with everything else.
*/
const CONTENT_PRESS_CSS =
"[&:active_[data-slot=button-content]]:scale-[0.95] motion-reduce:[&:active_[data-slot=button-content]]:scale-100";
const CONTENT_HELD_CSS =
"[&_[data-slot=button-content]]:scale-[0.95] motion-reduce:[&_[data-slot=button-content]]:scale-100";
export function Button({
className,
variant,
size,
asChild = false,
loading = false,
loader,
loadingLabel = "Loading",
pressScale = true,
pressed = false,
pressTarget = "button",
disabled,
children: childrenProp,
ref,
...props
}: ButtonProps) {
const children = useSlotChild(childrenProp);
const isDisabled = disabled || loading;
const icons = iconEdges(children, asChild);
const reduceMotion = useReducedMotion();
const pressesContent = pressScale && pressTarget === "content";
const innerContent =
asChild && isValidElement<{ children?: ReactNode }>(children)
? children.props.children
: children;
const labelled = loading ? (
<ButtonLoading label={loadingLabel} loader={loader}>{innerContent}</ButtonLoading>
) : (
innerContent
);
/**
* The wrapper only exists when the content is what presses, and it re-establishes
* the row the button's own flex was providing — `gap-[inherit]` so the icon keeps
* exactly the spacing its size gave it, rather than a second copy of that number.
* The spinner stays absolute against the button, since this span isn't positioned.
*/
const content = !pressesContent ? (
labelled
) : asChild ? (
<span
data-slot="button-content"
className="inline-flex items-center gap-[inherit] transition-[scale] duration-(--duration-fast) ease-out"
>
{labelled}
</span>
) : (
<MotionSpan
data-slot="button-content"
className="inline-flex items-center gap-[inherit]"
variants={PRESS_VARIANTS}
transition={spring.press}
>
{labelled}
</MotionSpan>
);
if (asChild && !isValidElement(children)) {
throw new Error("Button expects a single React element child when using `asChild`.");
}
const childElement = children as ReactElement<{
children?: ReactNode;
onClick?: (event: MouseEvent<HTMLElement>) => void;
role?: string;
}>;
const child = asChild
? cloneElement(
childElement,
{
// Base UI correctly guards the Button's own handler. The child's handler is
// composed later, so suppress it here as well while disabled/loading.
onClick: isDisabled ? (event) => event.preventDefault() : childElement.props.onClick,
// `asChild` is visual composition: an anchor must remain a link, not be
// announced as a button merely because it wears Button's styles.
role: undefined,
},
content,
)
: null;
return (
<BaseButton
{...props}
ref={ref}
className={cn(
buttonVariants({ variant, size }),
asChild &&
pressScale &&
!pressesContent &&
"transition-[background-color,box-shadow,color,opacity,scale] active:scale-[0.95] motion-reduce:active:scale-100",
// Held state for the CSS-press (`asChild`) path. `scale` is a single CSS
// property, so this and `active:scale-[0.95]` resolve to one value (0.95) rather
// than multiplying — no compounding when a held button is pressed to dismiss.
asChild && pressScale && !pressesContent && pressed && "scale-[0.95] motion-reduce:scale-100",
asChild && pressesContent && CONTENT_PRESS_CSS,
asChild && pressesContent && pressed && CONTENT_HELD_CSS,
className,
)}
disabled={isDisabled}
nativeButton={!asChild}
// State on the element, not just in the closure. Anything downstream — a
// parent's CSS, a test, a screenshot diff — can now see what this button is
// doing without reaching into React.
data-variant={variant ?? "primary"}
data-size={size ?? "md"}
data-loading={loading || undefined}
data-pressed={pressed || undefined}
data-icon-start={icons.start || undefined}
data-icon-end={icons.end || undefined}
aria-busy={loading || undefined}
render={asChild ? child! : (buttonProps) => {
// Base UI owns the button semantics and disabled behavior. Motion only owns
// the native button's press spring. `asChild` uses CSS press feedback because
// Motion cannot wrap an arbitrary child without creating invalid nested DOM.
const motionProps = buttonProps as HTMLMotionProps<"button">;
// Which *element* moves is the only difference: the button animates the scale
// itself, or it names the state and the content span animates it. Same spring
// either way, and the gesture stays on the button so the whole hit area
// responds, not just the text.
const held = pressed && pressScale;
const pressProps = {
// While `pressed`, the tap is suppressed and the hold owns the scale — one
// property (Motion's transform), one spring — so pressing to dismiss doesn't
// stack a second 0.95 or hand the scale-back-up to a different timing curve.
whileTap:
reduceMotion || isDisabled || pressed || !pressScale
? undefined
: pressesContent
? "pressed"
: { scale: PRESS_SCALE },
animate: reduceMotion
? undefined
: pressesContent
? held
? "pressed"
: "rest"
: { scale: held ? PRESS_SCALE : 1 },
transition: spring.press,
};
return (
<MotionButton {...motionProps} {...pressProps}>
{content}
</MotionButton>
);
}}
/>
);
}
/**
* Which edges of the button hold an icon, so the variants can shave 2px off that
* side and optically re-centre the content.
*
* An element on an edge with something beside it is an icon; an element that's the
* *only* thing there is an icon-only button, which wants its padding symmetric.
* Fragments are flattened first, or `<>{cond && <Icon/>}{label}</>` — the shape
* every conditional icon takes — would look like a single child.
*/
function iconEdges(children: ReactNode, asChild: boolean): { start: boolean; end: boolean } {
// With `asChild` the icons live inside the child element, not beside it.
const content =
asChild && isValidElement<{ children?: ReactNode }>(children)
? children.props.children
: children;
const parts = flatten(content);
if (parts.length < 2) return { start: false, end: false };
return {
start: isValidElement(parts[0]),
end: isValidElement(parts[parts.length - 1]),
};
}
function flatten(children: ReactNode): ReactNode[] {
// Children.toArray already drops null/undefined/booleans, so an unrendered
// conditional icon doesn't count as an edge.
return Children.toArray(children).flatMap((child) =>
isValidElement<{ children?: ReactNode }>(child) && child.type === Fragment
? flatten(child.props.children)
: [child],
);
}
/**
* The label keeps its place in the layout and fades out; the spinner is absolutely
* positioned over it. Swapping the label *for* a spinner would resize the button
* mid-click, moving the target out from under the cursor.
*
* `aria-hidden` on the faded label plus a live-region-free status text means a
* screen reader hears "Loading", not the stale label it can no longer act on.
*/
function ButtonLoading({ label, loader, children }: { label: string; loader?: ReactNode; children: ReactNode }) {
return (
<>
<span
aria-hidden="true"
className="inline-flex items-center gap-[inherit] opacity-0 transition-[opacity] duration-(--duration-fast) ease-out"
>
{children}
</span>
<span className="absolute inset-0 inline-flex items-center justify-center">
{/* The shared box scales with the button's font size in either loader style. */}
<LoadingIndicator loader={loader} />
<span className="sr-only">{label}</span>
</span>
</>
);
}
/**
* @deprecated Supply a loader prop or use LoaderProvider. This compatibility export follows LoaderProvider as well.
*/
export function Spinner() {
return <LoadingIndicator className="size-4" />;
}
Add --dry-run to see every change before anything is written. Short names, browsing the catalog, and updating are covered in Installation.
Coding agents can start at /llms.txt, which has these commands and links every page as Markdown.
About
4AF is a personal library. I build it for my own projects and publish it as it grows.
Original work is released under MIT-0, so you can copy it without attribution. Some components include code under other licences; see the licence notices.