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

Share Compound Style Templates Between .astro Markup and Hydrated Island Components

Reusable Style Templates for .astro and Islands

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/astro/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/astro/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/astro/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.