Version 0.4.0 now released! See the release notes on GitHub Releases

→ Name a condition once, then reference it by @name in any style object.

Breakpoints & responsive layouts

Responsive design isn't what it was in the old days — it's worse. Between ultrawide monitors, 8K panels, and whatever odd form factor shipped this quarter, holding a layout together everywhere can take the kind of min-maxing you'd otherwise save for a round of CS2 — stretch-your-4:3-across-a-16:9-screen energy. And there are more cooks in the kitchen now, each wanting things done their way: desktop-first here, mobile-only there, dark and light for the techy crowd, and the occasional soul (me) who still wishes we designed for print — I'd take a fax over an email some days, though nobody seems to agree anymore. It all comes down to one question — under which conditions should these styles apply?

Salty's answer is one small idea you'll reuse everywhere: you name a condition once, and Salty remembers the name. After that you reference it by @name inside any style object — the same way you reference a token by {path}. Define tabletDown in one place and every component can just say "@tabletDown": { … } — no max-width: 900px retyped from memory, and nothing to import at the call site. Just the name you've gotten used to.

The rest is worked examples. If you want the deeper why behind Salty's build-time model, Scoping & specificity is the read.

How it works, briefly

You've got three tools, and they sort by how often you'll reach for the condition:

  • Inline — write the raw at-rule as a key: "@media (min-width: 600px)": { … }. Fine for a genuine one-off.
  • Named — define the query once with defineMediaQuery, then reference it by name: "@tabletDown": { … }. This is the one you'll use most, and the rest of this page leans on it.
  • Swap or scale the value itself — when it's not a block of styles that changes but a single value (a font size, a spacing step), reach for responsive tokens or viewport clamp instead. More on that in Example 4.

Everything is authored in TypeScript in a .css.ts file and compiled to a static stylesheet at build time — the media queries you write become ordinary @media rules in the output. Nothing here runs in the browser.

One honest catch worth stating up front, because it's the thing that trips people: the definition file has to reach the build once — re-exported from a styles barrel, or imported from salty.config.ts. That's a one-time wiring step, not a per-component import. Miss it and Salty won't error; it emits your @name as a literal at-rule that silently never matches. So when a breakpoint "isn't doing anything," the usual culprit is a definition file that never made it into the build, or a typo in the name. We'll come back to that.

Example 1 — Name your breakpoints once, use them everywhere

The pattern that carries the whole page: define a set of named queries in one central file, then use them by name across your components.

How you define it

Put your breakpoints in a single styles file so every component draws from the same source of truth. Here's the thing Salty is relaxed about — you get to decide which direction you name in, and it remembers whichever you pick:

/styles/media.css.ts
import { defineMediaQuery } from "@salty-css/react/config";

// Mobile-first — min-width, reading bottom-up.
export const tabletUp  = defineMediaQuery((media) => media.minWidth(768));
export const desktopUp = defineMediaQuery((media) => media.minWidth(1200));

// Desktop-first — max-width, reading top-down. The "Down" suffix is a nice tell.
export const largeMobileDown  = defineMediaQuery((media) => media.maxWidth(900));
export const smallMobileDown  = defineMediaQuery((media) => media.maxWidth(400));
export const smallDesktopDown = defineMediaQuery((media) => media.maxWidth(1100));

Two things to notice. First, the names are entirely yours — Salty doesn't ship a fixed breakpoint scale you have to adopt. Second, prefer a name that says what it means (smallDesktopDown) over one that bakes in the number (below1100); the whole point of naming is that you can retune the value later without every call site lying about it.

Pick one direction per project and stay consistent. Mixing …Up and …Down freely across the same tree works mechanically, but it makes the cascade harder to reason about at a glance — and you're the one who has to read it later.

For what it's worth, I lean desktop-first and don't apologize for it — I'd rather a Salty site be genuinely great on desktop than blandly fine on every screen ever made. That's a deliberate stance, not an oversight: Salty is built for design-led sites that are often at their best on a big canvas, and I think more projects should pick a stance like that and commit to it (while still staying accessible to whoever shows up). Your project might lean the other way — mobile-only apps absolutely exist — so name in whichever direction matches where your design actually lives.

How you use it

Reference a query by its export name with an @ prefix, as a key inside any style object. The block underneath applies only when that query matches:

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

export const ResponsiveBox = styled("div", {
  base: {
    display: "grid",
    gridTemplateColumns: "1fr 1fr",
    gap: "{spacing.medium}",
    padding: "{spacing.large}",

    // Collapse to a single column on smaller screens.
    "@smallDesktopDown": {
      gridTemplateColumns: "1fr",
      gap: "{spacing.small}",
    },

    // Tighten padding further once we're phone-sized.
    "@largeMobileDown": {
      padding: "{spacing.medium}",
    },
  },
});

Same @name keys work anywhere a Salty style object is accepted — inside styled, className, defineGlobalStyles, defineTemplates. You define the breakpoint once; the whole system speaks its name.

And when a query isn't taking effect, walk these in order: is the media.css.ts export actually imported into the build graph (re-export it from a styles barrel, or import it once from salty.config.ts)? Is the name spelled exactly like the export? Is another rule simply winning the cascade — visible in DevTools, but losing on specificity? Those three cover almost every case.

Example 2 — Past width: dark mode, print, motion, and combining conditions

Breakpoints are the headline, but a media query is really just any condition, and Salty's builder covers the lot. This is where the "some sites want dark, some need print" list gets handled — same define-then-use rhythm, different conditions.

How you define it

The builder reads close to plain English, which is the point — you can tell what a query matches without decoding it:

/styles/media.css.ts (continued)
export const darkMode      = defineMediaQuery((media) => media.dark);
export const printMode     = defineMediaQuery((media) => media.print);
export const reducedMotion = defineMediaQuery((media) => media.reducedMotion);
export const portrait      = defineMediaQuery((media) => media.orientation("portrait"));

Beyond minWidth / maxWidth, the common builder methods are dark / light (color scheme), reducedMotion, print, orientation, plus minHeight / maxHeight, screen, and an escape hatch custom(value) for anything not covered. The Media queries reference has the full list.

Conditions combine with .and(...) and .or(...), and the right-hand side takes any nested expression, so you can build a query as specific as you need:

Example
// Tablet-sized AND held in portrait.
export const tabletPortrait = defineMediaQuery((media) =>
  media.minWidth(768).and(media.maxWidth(1024)).and(media.orientation("portrait")),
);

// Print, OR a small screen in landscape (think: cheat-sheet layouts).
export const printOrLandscapeMobile = defineMediaQuery((media) =>
  media.print.or(media.maxWidth(640).and(media.orientation("landscape"))),
);

There's also a fluent form for simple joins that reads even more like a sentence — media.minWidth(720).and.dark becomes @media (min-width: 720px) and (prefers-color-scheme: dark).

How you use it

Then they're just more @name keys. A component can tweak itself for dark mode, drop its shadow for print, and respect a reduced-motion preference, all in one object:

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

export const Panel = styled("section", {
  base: {
    padding: "{spacing.large}",
    background: "#fff",
    boxShadow: "0 8px 24px rgba(0, 0, 0, 0.08)",
    transition: "transform 150ms ease",

    "@darkMode": {
      background: "#141414",
      boxShadow: "0 8px 24px rgba(0, 0, 0, 0.6)",
    },

    // Ink is not a screen — flatten it for print.
    "@printMode": {
      boxShadow: "none",
      padding: 0,
    },

    // Honor the user's motion preference.
    "@reducedMotion": {
      transition: "none",
    },
  },
});

A note on dark mode specifically: keying @darkMode directly onto a component is perfect for a one-off tweak. But if dark/light is a system-wide concern — every surface flipping together — that's really theming, and it's better modeled as a switchable token layer than repeated @darkMode blocks in every component. Theming covers both the OS-driven (prefers-color-scheme) and the user-toggled versions.

Example 3 — Container queries: respond to the box, not the browser

Here's the limit of everything above: a media query only ever asks about the viewport. It has no idea how much room the component itself was actually given. Drop the same card into a wide main column and a narrow sidebar and a media query treats them identically, because the window is the same width in both — even though the card clearly isn't.

Container queries fix exactly that. The component adapts to the size of its container instead of the screen, so one component can lay out two different ways depending on where it's placed, with no JavaScript measuring anything. For design-system primitives that live in many slots, this is the tool you actually wanted all along.

How you define it

Two small pieces. First, mark whatever the card sits inside — a sidebar, a grid cell, a main column — as a container with containerType: "inline-size". That element becomes something its descendants are allowed to measure themselves against. (Use "size" if you need to query height too; inline-size — width only — is the common, cheaper choice.)

Second, name the query itself, exactly like a breakpoint from Example 1, so it reads the same everywhere. A container query is just a custom query under the hood:

/styles/media.css.ts (continued)
export const containerSmallDown = defineMediaQuery((media) =>
  media.custom("container (max-width: 480px)"),
);

You can write the raw "@container (max-width: 480px)": { … } inline, but lead with the named version — it's the same one-source-of-truth win as every other breakpoint on this page, and @containerSmallDown is a lot easier to think about than a bare width.

How you use it

Now the card reads whichever container is above it and lays itself out to fit. Its base is the roomy two-column layout; when the container gets narrow, the card tightens itself down:

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

// Any slot the card can live in becomes a container it can read.
export const Slot = styled("div", {
  base: { containerType: "inline-size" },
});

// The card lays itself out to fit whatever slot it's dropped into.
export const Card = styled("article", {
  base: {
    display: "grid",
    gridTemplateColumns: "1fr 2fr",
    gap: "1.5rem",
    padding: "2rem",

    // Cramped slot? The card notices, and shrinks itself down.
    "@containerSmallDown": {
      gridTemplateColumns: "1fr",
      gap: "1rem",
      padding: "1rem",
    },
  },
});

Drop that Card into a wide Slot in your main column and it spreads into two columns; drop the exact same component into a narrow Slot in a sidebar and it quietly stacks itself. No prop, no JavaScript, no page-level coordination — the card reads the room and resizes on its own.

Which is where it gets a little uncanny. Your components are now noticing how much space they've been handed and rearranging themselves to fit, entirely without you — cards gaining sentience, growing and shrinking as needed. The self-assembling nanobots of CSS, reconfiguring on their own. (They can only do layout. For now.)

One genuinely nice bonus, since you're already querying the container: container query units. cqi is 1% of the container's inline size (with cqw, cqh, cqb, cqmin, cqmax alongside it), so a card can size its own type against its own width — scaling smoothly wherever it lands, no breakpoint required:

Example
base: {
  fontSize: "max(1.5rem, 1rem + 2cqi)",
},

When to use which: reach for a container query when a component should be placement-independent — it adapts to whatever room it's handed. Reach for a media query for genuinely page-level, viewport-driven decisions (the overall grid, the nav collapsing, print). Most real layouts use both, and that's fine.

Example 4 — When a breakpoint is the wrong tool

Worth saying plainly, because it's the common over-reach: not every responsive change wants a breakpoint. Breakpoints move in hard steps — nothing happens, nothing happens, then everything jumps at 900px. Sometimes that's exactly right (a two-column grid should snap to one column). Sometimes it's a fluid value pretending to be a stepped one, and you feel the jump.

Two lighter tools handle the smooth cases, and each has its own page:

  • Responsive tokens — declare a token that swaps value when a named query matches. The component reads one token name; the value changes underneath it at the breakpoint. Good for spacing scales and type sizes that step deliberately, without scattering @media blocks through every component.
  • Viewport clamp — fluid clamp() sizing tuned to a reference screen, so a value scales continuously between a floor and a ceiling with no step at all. This is the one to reach for when a font size should glide from phone to desktop rather than jump.

They compose with everything above — a viewport clamp for the base size, then a media query switching to a clamp tuned for smaller screens is a common, sturdy pattern. But if a change is really "one value, smoothly," start on those pages rather than adding another breakpoint here.