Attribute mode — React & Astro
Spread recipe results onto elements in attribute naming mode without dropping variant attrs
Attribute mode (createStyles({ mode: 'attribute' })) resolves recipe calls to a
ComponentAttrsResult: a base class plus data-* variant attributes. This guide
covers the binding helpers you need in React, Astro, and other JSX frameworks.
See also: Components — attribute-driven variants.
Quick reference
| Goal | Use |
|---|---|
| Spread with no extra classes | {...result.props} |
Spread + consumer className |
{...mergeProps(result, className)} |
| Multiple slots on one element | {...combine(a.root, a.linkRoot, className)} |
| Class string only (scripts, hooks) | result.className |
import { mergeProps, combine, cx } from 'typestyles';
{...result.props} — zero overrides
When the element needs only the recipe output and no external className, spread
.props directly:
const s = button({ tone: 'accent', size: 'md' });
<button {...s.props}>Save</button>;
// => className="button" data-tone="accent" data-size="md"
In Astro:
---
const s = stack({ direction: 'column', gap: 'md' });
---
<div {...s.props}><slot /></div>
{...mergeProps(result, className)} — default binding
Most components accept a consumer className. Use mergeProps to merge attrs and
classes without dropping variant attributes:
interface ButtonProps {
tone?: 'accent' | 'neutral';
className?: string;
children: React.ReactNode;
}
export function Button({ tone = 'accent', className, children }: ButtonProps) {
const s = button({ tone });
return <button {...mergeProps(s, className)}>{children}</button>;
}
---
interface Props {
class?: string;
}
const { class: className } = Astro.props;
const s = stack({ direction: 'column', gap: 'md' });
---
<div {...mergeProps(s, className)}><slot /></div>
mergeProps also accepts a plain class string when you only need class merging:
<div {...mergeProps('card-base', isActive && 'card-active', className)} />
combine — multiple slots on one element
Multi-slot recipes return per-slot results. When two slots share a single DOM node
(for example a card root that is also the link surface), use combine:
const c = clickableCard({ layout: 'horizontal' });
<a {...combine(c.root, c.linkRoot, className)} href={href}>
…
</a>;
State modifiers that are class-only slots (no attrs) compose cleanly:
<button {...combine(s.button, !copied && s.buttonIdle, copied && s.buttonCopied, className)} />
Prefer a single recipe call with compound variants when every attribute must stay
consistent. Reach for combine when slots only overlap by class name on one node.
In development, combine warns when the same attribute key appears with different
values (last wins) or when multiple slots with attrs merge onto one element.
Anti-pattern: className={cx(result)}
cx() joins class strings via toString(). For ComponentAttrsResult, that
drops variant attributes — styling silently breaks.
// Wrong — data-tone never reaches the DOM
<button className={cx(s, className)} />
// Right
<button {...mergeProps(s, className)} />
In development, TypeStyles logs a warning when cx() receives a
ComponentAttrsResult. Use mergeProps or combine instead.
cx remains the right tool for plain strings, semantic/BEM class names, and
Theme values:
cx('layout-grid', isWide && 'layout-grid--wide', className);
Imperative class strings
When you need a class string without spreading (DOM APIs, data-class-* hooks,
test selectors), use .className:
element.classList.add(s.root.className);
For debugging, .attrs exposes the attribute map without className.
SSR
mergeProps and combine return plain Record<string, string> objects — safe to
serialize and spread during server render. No extra setup beyond your usual
typestyles SSR flow.