Version 0.1.0 just released! Check out the release notes from GitHub Releases

Render an h2 by Default, Switch to h1 or span at the Call Site, Keep the Visual Style Consistent

Polymorphic Heading in React

Most landing pages want one visual headline style that has to render as <h1> at the top of the page, <h2> inside a section, and sometimes <span> inside a card. Plain CSS-in-JS usually solves this with one component per tag, all importing the same styles — which gets old fast.

Salty CSS's styled() takes an element option for the default tag, and the rendered component accepts an as prop for per-instance overrides. The styles compile once.

The component

// /components/heading.css.ts
import { styled } from "@salty-css/react/styled";

// Default tag is <h2>; `as` swaps it at the call site.
export const Heading = styled("h2", {
  base: {
    fontFamily: "system-ui, sans-serif",
    fontWeight: 700,
    lineHeight: 1.1,
    letterSpacing: "-0.02em",
    margin: 0,
  },
  variants: {
    size: {
      sm: { fontSize: "1.25rem" },
      md: { fontSize: "1.75rem" },
      lg: { fontSize: "2.5rem" },
      xl: { fontSize: "3.5rem" },
    },
    tone: {
      default: { color: "{theme.color}" },
      muted: { color: "{theme.altColor}" },
      brand: { color: "{theme.highlight}" },
    },
  },
  defaultVariants: { size: "md", tone: "default" },
});

Using it

The as prop overrides the rendered tag without changing the style props. Variant props (size, tone) stay typed.

// Page hero — semantically the page's H1, but render at "xl" size.
<Heading as="h1" size="xl">Build interfaces that ship as plain CSS</Heading>

// Section subtitle — h2 is the default, no `as` needed.
<Heading tone="muted">What you'll learn</Heading>

// Visual headline inside a card that already lives under an h2.
<Heading as="span" size="sm" tone="brand">Recipe</Heading>

What it produces

styled("h2", { … }) emits one class per base and per variant. Switching as does not create a new class — Salty just picks a different React element type to render into. The runtime overhead is the same conditional Salty already does for every styled component (a tiny class-picking helper). No bundle is shipped for the styling.

Gotchas

  • Headings are an SEO contract. Pick as based on the document outline, not the visual size. The whole point of the recipe is that the two are now independent — use it.
  • defaultVariants apply before as. Passing as="span" keeps the size/tone defaults; it doesn't reset them.
  • Wrapping a third-party component? Pass it as the first argument to styled(...) and add passProps for the props the wrapped component needs. The as prop still works on the result. See the Overrides reference.