+```
+
+#### Token Types
+
+| Prefix | Output | Example |
+|--------|--------|---------|
+| `$name` | `--name` | `$spacing: '2x'` → `--spacing: calc(2 * var(--gap))` |
+| `#name` | `--name-color` + `--name-color-rgb` | `#accent: '#purple'` → `--accent-color: var(--purple-color)` |
+
+#### Key Differences from `styles`
+
+| Feature | `styles` | `tokens` |
+|---------|----------|----------|
+| Output | CSS classes | Inline styles |
+| State mapping | ✅ Supported | ❌ Not supported |
+| Responsive arrays | ✅ Supported | ❌ Not supported |
+| Performance | Cached & deduplicated | Per-instance |
+| Use case | Static styling | Dynamic values |
+
+#### Merging Priority
+
+Tokens are merged in order (later overrides earlier):
+1. Default tokens (from `tasty({ tokens: {...} })`)
+2. Instance tokens (from `
`)
+3. Instance `style` prop (from `
`)
+
+```jsx
+const Card = tasty({
+ tokens: { $spacing: '1x' },
+});
+
+// Instance tokens override defaults
+
+
+// style prop overrides everything (not recommended - see warning below)
+
+// --spacing will be '100px'
+```
+
+> **⚠️ Warning:** Using the `style` prop directly is **not recommended** for general styling. It should only be used in rare cases when you need to spread raw styles from a third-party library to an element. For all other cases, use `tokens` or `styles` props instead.
+
+#### Valid Token Values
+
+- **String** - Processed through tasty parser (`'2x'` → `calc(2 * var(--gap))`)
+- **Number** - Converted to string (`42` → `'42'`); `0` stays as `'0'`
+- **undefined/null** - Silently skipped (no CSS property output)
+
+```jsx
+// ✅ Valid
+tokens={{ $spacing: '2x', $size: 100, $zero: 0 }}
+
+// ❌ Invalid - object values not allowed (no state mapping)
+tokens={{ $spacing: { '': '1x', hovered: '2x' } }}
+// Will log warning and skip this token
+```
+
+#### Use Cases
+
+```jsx
+// Dynamic theming
+const ThemedCard = tasty({
+ tokens: { '#card-bg': '#surface', '#card-border': '#border' },
+ styles: {
+ fill: '#card-bg',
+ border: '1bw solid #card-border',
+ },
+});
+
+// Override theme per-instance
+
+
+// Dynamic sizing based on props
+function DynamicComponent({ columns }) {
+ return
;
+}
+
+// Responsive values via parent CSS
+const Parent = tasty({
+ styles: {
+ '--child-spacing': ['4x', '2x', '1x'], // Responsive in styles
+ },
+});
+const Child = tasty({
+ styles: {
+ padding: '$child-spacing', // References parent's responsive property
+ },
+});
+```
+
## ✅ Best Practices & Anti-patterns
### Do's ✅
@@ -952,7 +1075,10 @@ styles: {
// ❌ Don't change styles prop at runtime (performance)
const [dynamicStyles, setDynamicStyles] = useState({});
-
// Use style prop for dynamic values
+
// Use tokens prop for dynamic values
+
+// ❌ Don't use style prop for custom styling
+
// Only for spreading third-party library styles
```
#### Tasty vs Native CSS Properties
@@ -981,12 +1107,16 @@ styles: {
### Performance Tips ⚡
```jsx
-// ✅ Use native style prop for dynamic values
+// ✅ Use tokens prop for dynamic CSS custom properties
+// ⚠️ Avoid using style prop (only for spreading third-party library styles)
+// ❌ Don't do this for custom styling:
+
+
// ✅ Avoid changing styles prop at runtime
// ❌ Don't do this:
diff --git a/src/tasty/index.ts b/src/tasty/index.ts
index c4d57c41c..ed9a26f34 100644
--- a/src/tasty/index.ts
+++ b/src/tasty/index.ts
@@ -11,10 +11,13 @@ export * from './providers/BreakpointsProvider';
export * from './utils/mergeStyles';
export * from './utils/warnings';
export * from './utils/getDisplayName';
+export * from './utils/processTokens';
export * from './injector';
export * from './debug';
export type {
TastyProps,
+ TastyElementOptions,
+ TastyElementProps,
GlobalTastyProps,
AllBasePropsWithMods,
} from './tasty';
@@ -38,6 +41,9 @@ export type {
GlobalStyledProps,
TagName,
Mods,
+ ModValue,
+ Tokens,
+ TokenValue,
} from './types';
export type {
StylesInterface,
diff --git a/src/tasty/styles/dimension.test.ts b/src/tasty/styles/dimension.test.ts
index d69ac3ce1..92096ab91 100644
--- a/src/tasty/styles/dimension.test.ts
+++ b/src/tasty/styles/dimension.test.ts
@@ -138,4 +138,25 @@ describe('dimensionStyle – width & height helpers', () => {
expect(res['min-height']).toBe('100%');
expect(res['max-height']).toBe('100%');
});
+
+ test('numeric zero height', () => {
+ const res = heightStyle({ height: 0 }) as any;
+ expect(res.height).toBe('0px');
+ expect(res['min-height']).toBe('initial');
+ expect(res['max-height']).toBe('initial');
+ });
+
+ test('string zero height', () => {
+ const res = heightStyle({ height: '0' }) as any;
+ expect(res.height).toBe('0');
+ expect(res['min-height']).toBe('initial');
+ expect(res['max-height']).toBe('initial');
+ });
+
+ test('numeric zero width', () => {
+ const res = widthStyle({ width: 0 }) as any;
+ expect(res.width).toBe('0px');
+ expect(res['min-width']).toBe('initial');
+ expect(res['max-width']).toBe('initial');
+ });
});
diff --git a/src/tasty/styles/dimension.ts b/src/tasty/styles/dimension.ts
index 2efe0c25a..966f1e094 100644
--- a/src/tasty/styles/dimension.ts
+++ b/src/tasty/styles/dimension.ts
@@ -16,7 +16,7 @@ export function dimensionStyle(name) {
};
}
- if (!val) return '';
+ if (val == null) return '';
if (typeof val === 'number') {
val = `${val}px`;
diff --git a/src/tasty/styles/list.ts b/src/tasty/styles/list.ts
index 058b552a8..1ca8612c7 100644
--- a/src/tasty/styles/list.ts
+++ b/src/tasty/styles/list.ts
@@ -4,6 +4,8 @@ export const BASE_STYLES = [
'preset',
'hide',
'whiteSpace',
+ 'opacity',
+ 'transition',
] as const;
export const POSITION_STYLES = [
@@ -34,7 +36,6 @@ export const BLOCK_OUTER_STYLES = [
'border',
'radius',
'shadow',
- 'opacity',
'outline',
] as const;
diff --git a/src/tasty/styles/preset.ts b/src/tasty/styles/preset.ts
index c6a79ba75..16b3d9899 100644
--- a/src/tasty/styles/preset.ts
+++ b/src/tasty/styles/preset.ts
@@ -62,9 +62,11 @@ export function presetStyle({
const isStrong = mods.includes('strong');
const isItalic = mods.includes('italic');
const isIcon = mods.includes('icon');
+ const isTight = mods.includes('tight');
mods = mods.filter(
- (mod) => mod !== 'bold' && mod !== 'italic' && mod !== 'icon',
+ (mod) =>
+ mod !== 'bold' && mod !== 'italic' && mod !== 'icon' && mod !== 'tight',
);
const name = mods[0] || 'default';
@@ -115,6 +117,10 @@ export function presetStyle({
styles['line-height'] = 'var(--icon-size)';
}
+ if (isTight) {
+ styles['line-height'] = 'var(--font-size)';
+ }
+
return styles;
}
diff --git a/src/tasty/tasty.test.tsx b/src/tasty/tasty.test.tsx
index c62bca181..bc4a80ca7 100644
--- a/src/tasty/tasty.test.tsx
+++ b/src/tasty/tasty.test.tsx
@@ -872,6 +872,147 @@ describe('style order consistency', () => {
});
});
+describe('tokens prop', () => {
+ it('should process $name tokens into CSS custom properties', () => {
+ const Element = tasty({});
+
+ const { container } = render(
);
+ const element = container.firstElementChild as HTMLElement;
+
+ expect(element.style.getPropertyValue('--spacing')).toBe(
+ 'calc(2 * var(--gap))',
+ );
+ });
+
+ it('should process #name color tokens into CSS custom properties', () => {
+ const Element = tasty({});
+
+ const { container } = render(
);
+ const element = container.firstElementChild as HTMLElement;
+
+ expect(element.style.getPropertyValue('--accent-color')).toBe(
+ 'var(--purple-color)',
+ );
+ expect(element.style.getPropertyValue('--accent-color-rgb')).toBe(
+ 'var(--purple-color-rgb)',
+ );
+ });
+
+ it('should merge default tokens with instance tokens', () => {
+ const Card = tasty({
+ tokens: { $spacing: '1x', $size: '10x' },
+ });
+
+ const { container } = render(
);
+ const element = container.firstElementChild as HTMLElement;
+
+ // Instance token overrides default
+ expect(element.style.getPropertyValue('--spacing')).toBe(
+ 'calc(4 * var(--gap))',
+ );
+ // Default token preserved
+ expect(element.style.getPropertyValue('--size')).toBe(
+ 'calc(10 * var(--gap))',
+ );
+ });
+
+ it('should merge tokens with style prop (style has priority)', () => {
+ const Element = tasty({});
+
+ const { container } = render(
+
,
+ );
+ const element = container.firstElementChild as HTMLElement;
+
+ // style prop overrides tokens
+ expect(element.style.getPropertyValue('--spacing')).toBe('100px');
+ });
+
+ it('should handle number values correctly', () => {
+ const Element = tasty({});
+
+ const { container } = render(
+
,
+ );
+ const element = container.firstElementChild as HTMLElement;
+
+ // 0 should stay as "0"
+ expect(element.style.getPropertyValue('--zero')).toBe('0');
+ // Other numbers are converted to string
+ expect(element.style.getPropertyValue('--number')).toBe('42');
+ });
+
+ it('should skip undefined and null token values', () => {
+ const Element = tasty({});
+
+ const { container } = render(
+
,
+ );
+ const element = container.firstElementChild as HTMLElement;
+
+ expect(element.style.getPropertyValue('--defined')).toBe(
+ 'calc(2 * var(--gap))',
+ );
+ expect(element.style.getPropertyValue('--undefined')).toBe('');
+ expect(element.style.getPropertyValue('--null')).toBe('');
+ });
+
+ it('should warn on object token values', () => {
+ const consoleWarnSpy = jest
+ .spyOn(console, 'warn')
+ .mockImplementation(() => {});
+
+ const Element = tasty({});
+
+ render(
+
,
+ );
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Object values are not allowed in tokens prop'),
+ );
+
+ consoleWarnSpy.mockRestore();
+ });
+
+ it('should work with variants', () => {
+ const Card = tasty({
+ tokens: { $spacing: '2x' },
+ styles: { padding: '$spacing' },
+ variants: {
+ compact: { padding: '1x' },
+ spacious: { padding: '4x' },
+ },
+ });
+
+ const { container } = render(
+
,
+ );
+ const element = container.firstElementChild as HTMLElement;
+
+ expect(element.style.getPropertyValue('--spacing')).toBe(
+ 'calc(3 * var(--gap))',
+ );
+ });
+
+ it('should handle hex color values for RGB extraction', () => {
+ const Element = tasty({});
+
+ const { container } = render(
);
+ const element = container.firstElementChild as HTMLElement;
+
+ // Should have color property
+ expect(element.style.getPropertyValue('--custom-color')).toBeTruthy();
+ // Should have RGB property
+ expect(element.style.getPropertyValue('--custom-color-rgb')).toBeTruthy();
+ });
+});
+
describe('tastyGlobal() API', () => {
beforeEach(() => {
// Configure injector for test environment with text injection
diff --git a/src/tasty/tasty.tsx b/src/tasty/tasty.tsx
index 5800cf333..fe39fb4c4 100644
--- a/src/tasty/tasty.tsx
+++ b/src/tasty/tasty.tsx
@@ -1,9 +1,11 @@
import {
+ AllHTMLAttributes,
ComponentType,
createElement,
FC,
forwardRef,
ForwardRefExoticComponent,
+ JSX,
PropsWithoutRef,
RefAttributes,
useContext,
@@ -17,11 +19,18 @@ import { allocateClassName, inject, injectGlobal } from './injector';
import { BreakpointsContext } from './providers/BreakpointsProvider';
import { BASE_STYLES } from './styles/list';
import { Styles, StylesInterface } from './styles/types';
-import { AllBaseProps, BaseProps, BaseStyleProps, Props } from './types';
+import {
+ AllBaseProps,
+ BaseProps,
+ BaseStyleProps,
+ Props,
+ Tokens,
+} from './types';
import { cacheWrapper } from './utils/cache-wrapper';
import { getDisplayName } from './utils/getDisplayName';
import { mergeStyles } from './utils/mergeStyles';
import { modAttrs } from './utils/modAttrs';
+import { processTokens, stringifyTokens } from './utils/processTokens';
import { RenderResult, renderStyles } from './utils/renderStyles';
import { ResponsiveStyleValue, stringifyStyles } from './utils/styles';
@@ -89,18 +98,34 @@ export type TastyProps<
V extends VariantMap,
DefaultProps = Props,
> = {
- /** The tag name of the element. */
- as?: string;
+ /** The tag name of the element or a React component. */
+ as?: string | ComponentType
;
/** Default styles of the element. */
styles?: Styles;
/** The list of styles that can be provided by props */
styleProps?: K;
element?: BaseProps['element'];
variants?: V;
-} & Partial> &
+ /** Default tokens for inline CSS custom properties */
+ tokens?: Tokens;
+} & Partial> &
Pick &
WithVariant;
+/**
+ * TastyElementOptions is used for the element-creation overload of tasty().
+ * It includes a Tag generic that allows TypeScript to infer the correct
+ * HTML element type from the `as` prop.
+ */
+export type TastyElementOptions<
+ K extends StyleList,
+ V extends VariantMap,
+ Tag extends keyof JSX.IntrinsicElements = 'div',
+> = Omit, 'as'> & {
+ /** The tag name of the element or a React component. */
+ as?: Tag | ComponentType;
+};
+
export interface GlobalTastyProps {
breakpoints?: number[];
}
@@ -109,6 +134,50 @@ export type AllBasePropsWithMods = AllBaseProps & {
[key in K[number]]?: ResponsiveStyleValue;
} & BaseStyleProps;
+/**
+ * Keys from BasePropsWithoutChildren that should be omitted from HTML attributes.
+ * This excludes event handlers so they can be properly typed from JSX.IntrinsicElements.
+ */
+type TastySpecificKeys =
+ | 'as'
+ | 'qa'
+ | 'qaVal'
+ | 'element'
+ | 'styles'
+ | 'breakpoints'
+ | 'block'
+ | 'inline'
+ | 'mods'
+ | 'isHidden'
+ | 'isDisabled'
+ | 'css'
+ | 'style'
+ | 'theme'
+ | 'tokens'
+ | 'ref'
+ | 'color';
+
+/**
+ * Props type for tasty elements that combines:
+ * - AllBasePropsWithMods for style props with strict tokens type
+ * - HTML attributes for flexibility (properly typed based on tag)
+ * - Variant support
+ *
+ * Uses AllHTMLAttributes as base for common attributes (like disabled),
+ * but overrides event handlers with tag-specific types from JSX.IntrinsicElements.
+ */
+export type TastyElementProps<
+ K extends StyleList,
+ V extends VariantMap,
+ Tag extends keyof JSX.IntrinsicElements = 'div',
+> = AllBasePropsWithMods &
+ WithVariant &
+ Omit<
+ Omit, keyof JSX.IntrinsicElements[Tag]> &
+ JSX.IntrinsicElements[Tag],
+ TastySpecificKeys | K[number]
+ >;
+
type TastyComponentPropsWithDefaults<
Props extends PropsWithStyles,
DefaultProps extends Partial,
@@ -120,10 +189,16 @@ type TastyComponentPropsWithDefaults<
[key in keyof Omit]: Props[key];
};
-export function tasty(
- options: TastyProps,
+export function tasty<
+ K extends StyleList,
+ V extends VariantMap,
+ Tag extends keyof JSX.IntrinsicElements = 'div',
+>(
+ options: TastyElementOptions,
secondArg?: never,
-): ComponentType & WithVariant>;
+): ForwardRefExoticComponent<
+ PropsWithoutRef> & RefAttributes
+>;
export function tasty(selector: string, styles?: Styles);
export function tasty<
Props extends PropsWithStyles,
@@ -254,6 +329,7 @@ function tastyElement(
styles: defaultStyles,
styleProps,
variants,
+ tokens: defaultTokens,
...defaultProps
} = tastyOptions;
@@ -271,6 +347,7 @@ function tastyElement(
as: originalAs,
styles: mergeStyles(defaultStyles, variantStyles),
styleProps,
+ tokens: defaultTokens,
...(defaultProps as Props),
} as TastyProps) as unknown as VariantFC;
return map;
@@ -283,6 +360,7 @@ function tastyElement(
as: originalAs,
styles: defaultStyles,
styleProps,
+ tokens: defaultTokens,
...(defaultProps as Props),
} as TastyProps) as unknown as VariantFC;
}
@@ -341,9 +419,15 @@ function tastyElement(
qa,
qaVal,
className: userClassName,
+ tokens,
+ style,
...otherProps
} = allProps as Record as AllBasePropsWithMods &
- WithVariant & { className?: string };
+ WithVariant & {
+ className?: string;
+ tokens?: Tokens;
+ style?: Record;
+ };
// Optimize propStyles extraction - avoid creating empty objects
let propStyles: Styles | null = null;
@@ -446,6 +530,28 @@ function tastyElement(
const injectedClassName = allocatedClassName;
+ // Merge default tokens with instance tokens (instance overrides defaults)
+ const tokensKey = stringifyTokens(tokens as Tokens | undefined);
+ const mergedTokens = useMemo(() => {
+ if (!defaultTokens && !tokens) return undefined;
+ if (!defaultTokens) return tokens as Tokens;
+ if (!tokens) return defaultTokens;
+ return { ...defaultTokens, ...tokens } as Tokens;
+ }, [tokensKey]);
+
+ // Process merged tokens into inline style properties
+ const processedTokenStyle = useMemo(() => {
+ return processTokens(mergedTokens);
+ }, [mergedTokens]);
+
+ // Merge processed tokens with explicit style prop (style has priority)
+ const mergedStyle = useMemo(() => {
+ if (!processedTokenStyle && !style) return undefined;
+ if (!processedTokenStyle) return style;
+ if (!style) return processedTokenStyle;
+ return { ...processedTokenStyle, ...style };
+ }, [processedTokenStyle, style]);
+
let modProps: Record | undefined;
if (mods) {
const modsObject = mods as unknown as Record;
@@ -468,6 +574,7 @@ function tastyElement(
...(modProps || {}),
...(otherProps as unknown as Record),
className: finalClassName,
+ style: mergedStyle,
ref,
} as Record;
diff --git a/src/tasty/types.ts b/src/tasty/types.ts
index 4b998e1b8..b3ef8e266 100644
--- a/src/tasty/types.ts
+++ b/src/tasty/types.ts
@@ -1,5 +1,5 @@
import { AriaLabelingProps } from '@react-types/shared';
-import { AllHTMLAttributes, CSSProperties } from 'react';
+import { AllHTMLAttributes, ComponentType, CSSProperties } from 'react';
import {
BASE_STYLES,
@@ -21,9 +21,32 @@ export interface GlobalStyledProps {
breakpoints?: number[];
}
-/** Type for element modifiers (mods prop) */
-export type Mods = {
- [key: string]: boolean | string | number | undefined | null;
+/** Allowed mod value types */
+export type ModValue = boolean | string | number | undefined | null;
+
+/**
+ * Type for element modifiers (mods prop).
+ * Can be used as a generic to define known modifiers with autocomplete:
+ * @example
+ * type ButtonMods = Mods<{
+ * loading?: boolean;
+ * selected?: boolean;
+ * }>;
+ */
+export type Mods = {}> = T & {
+ [key: string]: ModValue;
+};
+
+/** Token value: string or number (processed), undefined/null (skipped) */
+export type TokenValue = string | number | undefined | null;
+
+/**
+ * Tokens definition for inline CSS custom properties.
+ * - `$name` keys become `--name` CSS properties
+ * - `#name` keys become `--name-color` and `--name-color-rgb` CSS properties
+ */
+export type Tokens = {
+ [key: `$${string}` | `#${string}`]: TokenValue;
};
type Caps =
@@ -56,7 +79,8 @@ type Caps =
export interface BasePropsWithoutChildren