@chalp/vanilla-extract-styled
v2.0.1
Published
A small wrapper/utility for using `@vanilla-extract/recipes` and styled components in your project.
Readme
vanilla-extract-styled
A small wrapper/utility for using @vanilla-extract/recipes and styled components in your project.
This package provides a convenient styled API on top of vanilla-extract recipes and is intended to be published as a library (supports both ESM and CommonJS). The README describes installation, usage examples, and packaging/SSR recommendations.
Installation
We recommend using pnpm (or npm/yarn):
pnpm add @chalp/vanilla-extract-styled
# or
npm install @chalp/vanilla-extract-styledPeer dependencies
The package requires the following peer dependencies to be present in the project (they are not bundled):
- react (>=17)
- react-dom (>=17)
- @vanilla-extract/css
- @vanilla-extract/recipes
Install them in your project if they are not already present:
pnpm add react react-dom @vanilla-extract/css @vanilla-extract/recipes -wUsage
The key function: styled(elemType, ...recipes).
elemType— a React element/component: a string tag ('div','button', etc.) or a React component (functional or class)....recipes— any number of arguments, each of which can be:- a string — an already prepared
classNamethat will be appended to the resultingclassName; - a
RuntimeFn<VariantGroups>function from@vanilla-extract/recipes(a recipe function). This function will be called at render time with an options object collected from the component's props.
- a string — an already prepared
The return value is a React component. Its props are:
- all props of the original
elemType(for example, for'button'— standard button attributes); - plus automatically inferred variant props that correspond to the passed recipe functions;
- optional
className?: string.
How styled calls a recipe function
The implementation takes each recipe function and calls it like this:
- it inspects
recipeFn.classNames.variants(the keys of variant groups); - from the component props, it picks the value for each key — first checking the direct key (e.g.
variant), then falling back to the$-prefixed key (e.g.$variant). The direct key takes priority; - it constructs an options object and passes it to
recipeFn(options); - the result (a string of classes) is added to an array of
classes.
The final className is the list of all classes (in the order recipes were passed), plus the className from component props (if provided), joined with spaces.
Important: prop forwarding
The implementation distinguishes between native DOM elements ('div', 'button', etc.) and React components (functions/classes):
- For native DOM elements, any prop starting with
$("transient prop") is automatically stripped before being forwarded to the DOM. These props are still passed to recipe functions. - For React components, all props (including
$-prefixed) are forwarded as-is, letting the component handle them.
This lets you use recipe variant names that would conflict with DOM attributes without triggering React warnings:
const Button = styled('button', buttonRecipe);
// $variant → recipe gets { variant: 'primary' }, but <button> DOM does NOT receive $variant
<Button $variant="primary">Click</Button>
// variant → recipe gets { variant: 'primary' }, AND <button> DOM receives variant
<Button variant="primary">Click</Button>Priority: if both prop and $prop are provided, the unprefixed version takes precedence for the recipe, and the $-prefixed version is ignored.
| Condition | $prop | prop (no prefix) |
|-----------|---------|-------------------|
| Native element ('div', 'button') | ✅ recipe, ❌ DOM | ✅ recipe, ✅ DOM |
| Component (Button, styled wrapper) | ✅ recipe, ✅ props | ✅ recipe, ✅ props |
| Both provided simultaneously | fallback | priority |
About refs
The function returns a simple functional component without forwardRef. If you need ref forwarding, wrap the result in React.forwardRef manually.
Examples
- Simple example: a string class
import styled from '@chalp/vanilla-extract-styled';
const Box = styled('div', 'my-base-class');
// In render
// <Box className="local" /> => element with class="my-base-class local"- Recipe function (TypeScript)
Assume you have a recipe generated by @vanilla-extract/recipes:
import { recipe } from '@vanilla-extract/recipes';
export const buttonRecipe = recipe({
base: 'btn',
variants: {
variant: {
primary: 'btn--primary',
ghost: 'btn--ghost',
},
size: {
small: 'btn--sm',
medium: 'btn--md',
},
},
});Using styled:
import styled from '@chalp/vanilla-extract-styled';
import { buttonRecipe } from './button.css';
const Button = styled('button', buttonRecipe);
// In JSX
// <Button variant="primary" size="small">OK</Button>
// styled will call buttonRecipe({ variant: 'primary', size: 'small' })
// and add the resulting className to the element- Multiple recipes and string classes
const Card = styled('div', baseCardRecipe, shadowRecipe, 'ux-card');
// Order matters: classes will be in the order [baseCard, shadow, 'ux-card', props.className]- Custom React component
const Link = ({ href, className, children, ...rest }) => (
<a href={href} className={className} {...rest}>{children}</a>
);
const StyledLink = styled(Link, linkRecipe);
// <StyledLink href="/" active>Home</StyledLink>
// All props are passed to Link, and linkRecipe will be called with appropriate variant propsTypes (short)
The returned component is typed as:
ComponentType< ComponentPropsWithoutRef & VariantPropsFromMerged & { className?: string } >
This means TypeScript will try to infer variant props from the provided recipe functions (VariantPropsFromMerged<T>).
TypeScript example (detailed)
Below is a more detailed example of types and real usage. Types are taken from src/styled.ts and shown here to illustrate how types are inferred in user code.
// Type imports (assumes @vanilla-extract/recipes is installed)
import type { ComponentType, ElementType, ComponentPropsWithoutRef } from 'react';
import type { RuntimeFn, RecipeVariants } from '@vanilla-extract/recipes';
// Helper utilities (as in src/styled.ts)
type Resolve<T> = { [Key in keyof T]: T[Key]; } & {};
type BooleanMap<T> = T extends 'true' | 'false' ? boolean : T;
type RecipeStyleRule = any; // ComplexStyleRule | string — simplified for README
type VariantDefinitions = Record<string, RecipeStyleRule>;
type VariantGroups = Record<string, VariantDefinitions>;
type VariantSelection<Variants extends VariantGroups> = {
[VariantGroup in keyof Variants]?: BooleanMap<keyof Variants[VariantGroup]> | undefined;
};
// RecipeOrClass — either a runtime recipe function or a className string
type RecipeOrClass = RuntimeFn<VariantGroups> | string;
type EmptyObject = NonNullable<unknown>;
// Main type: merges variant prop types from all provided recipe functions
type VariantPropsFromMerged<T extends readonly RecipeOrClass[]> = T[number] extends infer V
? V extends RuntimeFn<VariantGroups>
? RecipeVariants<V>
: EmptyObject
: EmptyObject;
// Return component type
type StyledReturn<
E extends ElementType,
T extends readonly RecipeOrClass[],
> = ComponentType<ComponentPropsWithoutRef<E> & VariantPropsFromMerged<T> & { className?: string }>;Usage example (in your code):
import React from 'react';
import styled from '@chalp/vanilla-extract-styled';
import { recipe } from '@vanilla-extract/recipes';
// Example recipe (typical @vanilla-extract/recipes code)
export const buttonRecipe = recipe({
base: 'btn',
variants: {
variant: {
primary: 'btn--primary',
ghost: 'btn--ghost',
},
size: {
small: 'btn--sm',
medium: 'btn--md',
},
},
});
// 1) styled with an HTML tag
const Button = styled('button', buttonRecipe);
// TypeScript will infer props as:
// ComponentPropsWithoutRef<'button'> & VariantPropsFromMerged<[typeof buttonRecipe]> & { className?: string }
// Usage in JSX:
// <Button variant="primary" size="small">OK</Button>
// 2) styled with a custom React component
type LinkProps = { href: string; children?: React.ReactNode };
const Link = ({ href, className, children, ...rest }: LinkProps & { className?: string }) => (
<a href={href} className={className} {...rest}>{children}</a>
);
const linkRecipe = recipe({
base: 'link',
variants: {
active: { true: 'link--active' },
},
});
const StyledLink = styled(Link, linkRecipe);
// Prop types: LinkProps & VariantPropsFromMerged<[typeof linkRecipe]> & { className?: string }
// Usage: <StyledLink href="/" active>Home</StyledLink>Notes
- The first argument to
styledis either a string tag ('div','button') or a React component. - You can pass any number of arguments afterward: strings (pre-made class names) and/or recipe functions (
RuntimeFn<VariantGroups>). VariantPropsFromMerged<T>merges the variant types (RecipeVariants) from all provided recipe functions.- If you pass recipe functions that share variant group names, the resulting props will be merged accordingly (the behavior is defined by
RecipeVariantstypes from@vanilla-extract/recipes).
Helpers
The package exports color utilities grouped under the Color namespace, plus the ColorSpace enum:
import { Color, ColorSpace } from '@chalp/vanilla-extract-styled';Color.darken(value, amount, colorSpace?)— mixes the color withblack. Returns a CSScolor-mix(...)expression.- Example:
Color.darken('#ffcc00', 0.15)→"color-mix(in srgb, #ffcc00 15%, black)".
- Example:
Color.lighten(value, amount, colorSpace?)— mixes the color withwhite.- Example:
Color.lighten('#002244', 0.1)→"color-mix(in srgb, #002244 10%, white)".
- Example:
Color.opacify(value, amount, colorSpace?)— mixes the color withtransparent.- Example:
Color.opacify('#000000', 0.5)→"color-mix(in srgb, #000000 50%, transparent)".
- Example:
ColorSpace— enum with values'srgb'(default) and'oklab'.
amount is a number from 0 to 1. colorSpace defaults to ColorSpace.SRGB.
Examples of using helpers in recipes:
import { recipe } from '@vanilla-extract/recipes';
import { Color, ColorSpace } from '@chalp/vanilla-extract-styled';
export const alertRecipe = recipe({
base: 'alert',
variants: {
tone: {
info: { background: Color.opacify('#0af', 0.1) },
warn: { background: Color.darken('#ffcc00', 0.05) },
muted: { background: Color.lighten('#002244', 0.1, ColorSpace.OKLAB) },
},
},
});Recommendations
- When styling native DOM elements (
'div','button'), use the$prefix for props that should only be consumed by recipes and not forwarded to the DOM (e.g.,$variant,$size). See "Important: prop forwarding" above. styleddoes not automatically forward refs — wrap the result withReact.forwardRefif you need a ref.
Contributing and development
- Follow the project rules (ESLint, Airbnb styles).
- Run linting and build before creating a PR:
pnpm --filter @chalp/vanilla-extract-styled lint
pnpm --filter @chalp/vanilla-extract-styled build
# add tests/vitest as neededLicense
MIT
