The class:list Directive
Building dynamic class attributes in templates is one of those small tasks that quietly turns ugly. String concatenation, nested ternaries, and stray whitespace all add up to markup that’s hard to read and easy to break. Astro’s class:list directive solves this by accepting arrays, objects, and nested values, then flattening them into a single clean, deduplicated class string. It’s the idiomatic way to express “apply this class when that condition holds” without littering your template with template literals.
What class:list does
class:list is a built-in Astro template directive. Instead of passing a plain string to class, you pass a value to class:list and Astro resolves it into a normalized class string at render time. It accepts strings, arrays, objects, Sets, and any nesting of those, and it handles falsy entries gracefully by dropping them.
The directive is powered internally by Astro’s clsx-compatible logic, so if you’ve used clsx or classnames in React, the mental model is identical.
---
const isActive = true;
const variant = "primary";
---
<button
class:list={[
"btn",
variant,
{ "btn--active": isActive, "btn--disabled": false },
]}
>
Save
</button>
Output:
<button class="btn primary btn--active">Save</button>
Notice that btn--disabled was dropped because its value was false, and the array was flattened into a space-separated string.
The values you can pass
class:list accepts a small set of value shapes and resolves each according to predictable rules.
| Value type | Behavior | Example | Result |
|---|---|---|---|
| String | Included as-is | "card" | card |
| Array | Each item resolved and joined | ["a", "b"] | a b |
| Object | Keys included when their value is truthy | { on: true, off: false } | on |
Set | Members resolved like an array | new Set(["x", "y"]) | x y |
| Falsy values | Silently ignored | false, null, undefined, 0, "" | (nothing) |
| Nested | Arrays/objects nest arbitrarily | ["a", ["b", { c: true }]] | a b c |
Because falsy entries are dropped, you can inline conditions without guarding them:
---
const hasError = false;
const size = "lg";
---
<input
class:list={[
"field",
`field--${size}`,
hasError && "field--error",
]}
/>
Output:
<input class="field field--lg">
The hasError && "field--error" expression evaluates to false, which class:list quietly removes — no empty strings, no double spaces.
Why this beats template ternaries
Without class:list, the same conditional markup forces you into nested template literals that are noisy and error-prone:
---
const isActive = true;
const isMuted = false;
---
<!-- The hard-to-read way -->
<div class={`tab ${isActive ? "tab--active" : ""} ${isMuted ? "tab--muted" : ""}`}>
Tab
</div>
That produces stray whitespace (tab tab--active with a trailing/double space) and grows unreadable fast. The class:list equivalent is declarative and self-cleaning:
---
const isActive = true;
const isMuted = false;
---
<div class:list={["tab", { "tab--active": isActive, "tab--muted": isMuted }]}>
Tab
</div>
Output:
<div class="tab tab--active">Tab</div>
Astro automatically deduplicates repeated class names and trims whitespace. Passing
["btn", "btn", " btn "]resolves to a single cleanbtn.
Combining static and dynamic classes
A common pattern is component props that contribute classes on top of a static base. Pull the variant logic into the component script and feed everything to class:list.
---
interface Props {
variant?: "primary" | "secondary" | "ghost";
block?: boolean;
}
const { variant = "primary", block = false } = Astro.props;
---
<button
class:list={[
"button",
`button--${variant}`,
{ "button--block": block },
]}
>
<slot />
</button>
<style>
.button { padding: 0.5rem 1rem; border-radius: 0.375rem; }
.button--primary { background: #2563eb; color: white; }
.button--block { display: block; width: 100%; }
</style>
Because Astro renders this on the server with zero JavaScript shipped to the browser by default, the class resolution happens entirely at build/request time — the client just receives the final, static HTML. There’s no runtime cost and no hydration needed for purely presentational logic like this.
Working with passed-in class props
When a parent passes a class attribute to your component, capture it and merge it with your internal classes so consumers can extend styling:
---
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<article class:list={["card", className]}>
<slot />
</article>
Usage from a parent:
<Card class="card--featured shadow-lg" />
Output:
<article class="card card--featured shadow-lg"></article>
If the parent omits class, className is undefined and class:list simply drops it — no special-casing required.
Best practices
- Reach for
class:listwhenever a class depends on a condition; reserve plainclassfor fully static strings. - Use the object form (
{ "is-open": isOpen }) for boolean toggles and the array form for combining base, variant, and conditional groups. - Let falsy entries flow through naturally with
condition && "class-name"instead of writing ternaries that emit empty strings. - Accept a
classprop and merge it viaclass:listso consumers can extend a component’s styling without overriding it. - Keep variant-to-class mapping logic in the component script (the
---fence), passing only resolved values into the template. - Remember resolution happens on the server — there’s no client-side JavaScript cost, so prefer it over runtime class libraries for static markup.