Skip to content
Astro as templating 4 min read

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 typeBehaviorExampleResult
StringIncluded as-is"card"card
ArrayEach item resolved and joined["a", "b"]a b
ObjectKeys included when their value is truthy{ on: true, off: false }on
SetMembers resolved like an arraynew Set(["x", "y"])x y
Falsy valuesSilently ignoredfalse, null, undefined, 0, ""(nothing)
NestedArrays/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 clean btn.

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:list whenever a class depends on a condition; reserve plain class for 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 class prop and merge it via class:list so 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.
Last updated June 14, 2026
Was this helpful?