A Heading Whose Visual Style Is Authored Once and Whose Tag Comes From the Call Site in .astro
Polymorphic Heading in Astro
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/astro/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
asbased on the document outline, not the visual size. The whole point of the recipe is that the two are now independent — use it. defaultVariantsapply beforeas. Passingas="span"keeps thesize/tonedefaults; it doesn't reset them.- Wrapping a third-party component? Pass it as the first argument to
styled(...)and addpassPropsfor the props the wrapped component needs. Theasprop still works on the result. See the Overrides reference.