Version 0.2.0 released! Check out the release notes from GitHub Releases

Compose Reusable Style Patterns With defineTemplates — Share Compound Tokens Across Typed React Components

Reusable Style Templates for React

Templates are reusable style bundles. Where a variable holds a single value, a template holds a group of CSS properties — and can carry its own variants. Define a textStyle template once and apply it by key anywhere a style object is accepted; the compiler inlines the declarations at the call site.

Templates come in two forms: static (a plain nested object — each leaf is a path you apply by name) and function-based (a function that accepts a value at the call site and returns a style object). Both are defined with defineTemplates.

Creating Templates

You can define templates using the defineTemplates function:

// /styles/templates.css.ts
import { defineTemplates } from "@salty-css/core/factories";

export default defineTemplates({
  // Static templates for text styles
  textStyle: {
    headline: {
      small: {
        fontSize: "{fontSize.heading.small}",
        fontWeight: "600",
        lineHeight: "1.2",
      },
      regular: {
        fontSize: "{fontSize.heading.regular}",
        fontWeight: "600",
        lineHeight: "1.2",
      },
      large: {
        fontSize: "{fontSize.heading.large}",
        fontWeight: "700",
        lineHeight: "1.1",
      },
    },
    body: {
      small: {
        fontSize: "{fontSize.body.small}",
        lineHeight: "1.5",
      },
      regular: {
        fontSize: "{fontSize.body.regular}",
        lineHeight: "1.4",
      },
    },
  },

  // Dynamic function templates
  card: (padding: string) => {
    return {
      padding,
      borderRadius: "8px",
      boxShadow: "0 2px 10px rgba(0, 0, 0, 0.1)",
      backgroundColor: "#ffffff",
    };
  },
});

Using Templates in Components

Templates are applied by using the template name as a property in your component styles:

import { styled } from "@salty-css/react/styled";

// Using static templates
export const Heading = styled("h1", {
  base: {
    textStyle: "headline.large", // Apply the headline.large template
  },
});

export const Paragraph = styled("p", {
  base: {
    textStyle: "body.regular", // Apply the body.regular template
  },
});

// Using dynamic function templates
export const CardComponent = styled("div", {
  base: {
    card: "2rem", // Pass "2rem" to the card template function
  },
  variants: {
    compact: {
      true: {
        card: "1rem", // Pass "1rem" to the card template function
      },
    },
  },
});

Nesting Templates

Templates can be nested to create more complex reusable patterns:

export default defineTemplates({
  surface: {
    primary: {
      backgroundColor: "{colors.background.primary}",
      color: "{colors.text.primary}",
    },
    secondary: {
      backgroundColor: "{colors.background.secondary}",
      color: "{colors.text.secondary}",
    },
  },

  panel: (variant: "default" | "elevated") => {
    return {
      surface: "primary", // Apply the surface.primary template
      borderRadius: "8px",
      ...(variant === "elevated"
        ? {
            boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
          }
        : {}),
    };
  },
});

A real-world text-style template

Here's the text-style template this very website uses. It shows how base + per-key styles compose into a reusable typography system, with the actual font-size values pulled from responsive tokens:

// /styles/text-styles.css.ts
import { defineTemplates } from "@salty-css/core/factories";

export default defineTemplates({
  textStyle: {
    headline: {
      base: {
        fontWeight: "300",
        letterSpacing: "0.0125em",
        lineHeight: "1.2em",
        fontFamily: "var(--font-family-logo)",
      },
      small: { fontSize: "{fontSize.headline.small}" },
      regular: { fontSize: "{fontSize.headline.regular}" },
      large: { fontSize: "{fontSize.headline.large}" },
    },
    body: {
      base: {
        fontWeight: "300",
        letterSpacing: "0.0125em",
        lineHeight: "1.5em",
      },
      xs: { fontSize: "{fontSize.body.xs}" },
      small: { fontSize: "{fontSize.body.small}" },
      regular: {
        fontSize: "{fontSize.body.regular}",
        lineHeight: "1.4em",
      },
      large: {
        fontSize: "{fontSize.body.large}",
        lineHeight: "1.3em",
      },
    },
    code: {
      regular: {
        fontSize: "{fontSize.code.regular}",
        fontWeight: "300",
        letterSpacing: "0.025em",
        lineHeight: "1.66em",
      },
    },
  },
});

Then at call sites:

styled("h1", { base: { textStyle: "headline.large" } });
styled("p",  { base: { textStyle: "body.regular" } });
styled("code", { base: { textStyle: "code.regular" } });

Function templates: dynamic parameters

Function templates accept a value at the call site and return a style object. The argument can be a scalar ("2rem" above) or any shape you find useful — an options object, a token name, anything:

// /styles/templates.css.ts
import { defineTemplates } from "@salty-css/core/factories";

export default defineTemplates({
  // Function templates accept any value at the call site and return a style object.
  card: (padding: string) => ({
    padding,
    borderRadius: "8px",
    boxShadow: "0 2px 10px rgba(0, 0, 0, 0.1)",
    backgroundColor: "{theme.background}",
  }),

  // The argument can be a more structured object too.
  surface: ({ tone, elevated }: { tone: "muted" | "loud"; elevated?: boolean }) => ({
    background: tone === "loud" ? "{colors.brand.main}" : "{theme.altBackground}",
    color: tone === "loud" ? "white" : "{theme.color}",
    boxShadow: elevated ? "0 4px 12px rgba(0,0,0,0.12)" : "none",
  }),
});

Call sites pass the argument straight through:

import { styled } from "@salty-css/react/styled";

export const Card = styled("div", {
  base: {
    card: "2rem",
    surface: { tone: "muted", elevated: true },
  },
});

Use function templates when the same pattern needs slightly different values each time (padding, color, an entire variant object) and you don't want to materialise every combination as a static template.

Runtime style injection with {props.X}

Inside a template (or any Salty style object), the parser recognises {props.X} and {-props.X} placeholders and rewrites them into CSS custom properties. Salty CSS reads matching values from the rendered component's props at runtime and sets the custom property — so a single static rule can take dynamic per-instance values without becoming a new variant.

// /styles/templates.css.ts
import { defineTemplates } from "@salty-css/core/factories";

export default defineTemplates({
  highlight: {
    base: {
      // The `{props.tint}` placeholder maps to a CSS variable on the element.
      background: "{props.tint}",
      color: "{props.fg}",
    },
  },
});
import { styled } from "@salty-css/react/styled";

export const Pill = styled("span", {
  base: {
    highlight: true, // pull in the template
    padding: "2px 8px",
    borderRadius: "999px",
  },
});
<Pill tint="aqua" fg="black">New</Pill>
<Pill tint="tomato" fg="white">Hot</Pill>

Use the dash form ({-props.X}) when the prop name should be dash-cased in the generated CSS variable.

{props.X} is a runtime escape hatch — every prop you reference becomes a tiny extra inline style. Prefer variants for closed sets of values; reach for {props.X} when the value is genuinely open-ended (a user-picked color, an animation duration computed at runtime, etc.).

Template variants

Template nodes can declare named variant bundles — the same ergonomic as styled({ variants }), but reusable across components. A node becomes "rich" the moment it has a base or variants key; otherwise the existing flat shape (above) keeps working untouched.

// /styles/templates.css.ts
import { defineTemplates } from "@salty-css/core/factories";

export default defineTemplates({
  textStyle: {
    heading: {
      base: {
        fontFamily: "{fonts.heading}",
        lineHeight: "1.2",
      },
      variants: {
        weight: {
          light: { fontWeight: "300" },
          regular: { fontWeight: "600" },
          heavy: { fontWeight: "900" },
        },
        emphasis: {
          muted: { color: "{colors.text.muted}" },
          loud: { color: "{colors.brand.primary}", textTransform: "uppercase" },
        },
        italic: {
          true: { fontStyle: "italic" },
        },
      },
      defaultVariants: { weight: "regular" },
      compoundVariants: [
        { weight: "heavy", italic: true, css: { letterSpacing: "-0.005em" } },
      ],

      // Descendants — declare only what's different about them.
      small: { base: { fontSize: "{fontSize.heading.small}" } },
      large: {
        base: { fontSize: "{fontSize.heading.large}", lineHeight: "1.1" },
        defaultVariants: { weight: "heavy" },
      },
    },
  },
});

Call sites pick a path and (optionally) one variant value per axis. Two equivalent forms:

// String form — compact, ideal for one or two axes:
styled("h1", {
  base: {
    textStyle: "heading.large@weight=light",
  },
});

styled("h2", {
  base: {
    textStyle: "heading.large@weight=heavy&emphasis=loud&italic",
  },
});

// Object form — best with multiple axes / boolean variants:
styled("h1", {
  base: {
    textStyle: {
      name: "heading.large",
      weight: "heavy",
      emphasis: "loud",
      italic: true,
    },
  },
});

// Parent ref — picks up `heading.base` only, no size leaf:
styled("h2", { base: { textStyle: "heading" } });

A leaf inherits its parent's base, variants, defaultVariants, compoundVariants, and anyOfVariants. When a leaf re-declares an axis value (e.g., heading.large.variants.weight.heavy), it replaces the parent's bundle for that axis-value rather than merging — restate any properties you want to keep. The full resolution algorithm, edge cases, and design rationale live in docs/template-variants-spec.md.

When to use templates

Templates earn their keep for typography systems and other multi-property patterns that repeat across many components. Static templates suit closed systems where the full set of values is known ahead of time; function templates suit patterns that need a value passed in per use. Keep hierarchies reasonably flat — three levels is usually the limit before readability suffers. When an axis has two or more values that vary independently of the leaf (like a weight that applies to all heading sizes), declare it as a named variant on the parent node rather than duplicating it on every leaf.