
How to style child components with class:list in Astro?
To style a child component with class:list in Astro, make the child accept a class prop, combine it with its own classes using class:list={["alert", className]}, and spread the remaining props onto the root element with {...rest}. The parent can then write <Alert class="spaced" />, or even <Alert class:list={["spaced", { compact: isCompact }]} />, and its own scoped .spaced rule will apply to the child's root element. class:list takes an array of strings, objects and nested arrays, keeps only the truthy entries and joins them into a single class string.
Without these pieces, a class you pass to a child component simply disappears, because Astro components don't forward attributes automatically. In this article you'll learn how class:list works and what it accepts, how to build a child component that accepts classes from its parent, why the ...rest spread matters for scoped styles, how to type the props, how to use class:list with variants and Tailwind, and a few behaviours I found while testing on Astro 7.4.2.
What class:list Does
class:list is a template directive available on any element or component in an .astro file. It's powered by the small clsx library, and accepts:
- Strings: added as they are, so
"btn btn--lg"adds two classes. - Objects: each key is added if its value is truthy.
- Arrays: flattened, so you can nest lists.
- false, null, undefined, 0 and empty strings: skipped.
<span class:list={["hello goodbye", { world: true }, ["friend"], false, null, undefined]} />
That rendered as:
<span class="hello goodbye world friend"></span>
It replaces the template-literal juggling you'd otherwise need, such as class={`btn ${isActive ? "btn--active" : ""}`}, which leaves stray spaces and is hard to read once you have more than one condition.
Why Classes Don't Reach Child Components by Default
In HTML, class is an attribute of an element. In Astro, when you write <Alert class="spaced" />, class is a prop passed to the Alert component. Nothing happens to it unless the component reads it and puts it somewhere.
There's a second hurdle. The parent's styles are scoped to the parent. A .spaced rule in the page compiles to something like .spaced[data-astro-cid-lcdefpme], using the page's ID. The child's root element carries the child's ID, so even if the class gets there, the selector won't match unless the element also carries the parent's attribute.
Astro solves this by passing the parent's scope attribute to the child as a prop. The child has to put it on its root element, which is what ...rest does. Both pieces are explained more broadly in the post on scoped CSS in Astro.
Building a Child Component That Accepts Classes
Here's an alert component with its own variants plus support for classes from its parent:
---
// src/components/Alert.astro
import type { HTMLAttributes } from "astro/types";
interface Props extends HTMLAttributes<"div"> {
tone?: "info" | "success" | "danger";
dismissible?: boolean;
}
const { tone = "info", dismissible = false, class: className, ...rest } = Astro.props;
---
<div
class:list={["alert", `alert--${tone}`, { "alert--dismissible": dismissible }, className]}
role="status"
{...rest}
>
<slot />
</div>
<style>
.alert {
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid;
}
.alert--info { background: #eff6ff; border-color: #bfdbfe; }
.alert--success { background: #f0fdf4; border-color: #bbf7d0; }
.alert--danger { background: #fef2f2; border-color: #fecaca; }
.alert--dismissible { padding-right: 3rem; }
</style>
The important lines:
class: classNamerenames the prop while destructuring, becauseclassis a reserved word in JavaScript.class:list={[..., className]}merges the component's own classes with the parent's. If the parent passes nothing,classNameisundefinedand is skipped.{...rest}forwards everything else, including the parent'sdata-astro-cid-*attribute and ordinary attributes likeidoraria-live.
Styling the Child from the Parent
Now the parent can position and adjust the alert with its own scoped CSS:
---
// src/pages/index.astro
import Alert from "../components/Alert.astro";
const isCompact = true;
---
<main>
<Alert tone="success" class="spaced">Saved.</Alert>
<Alert tone="danger" dismissible class:list={["spaced", { compact: isCompact }]} id="err">
Failed.
</Alert>
</main>
<style>
.spaced {
margin-block: 1.5rem;
}
.compact {
padding: 0.5rem;
}
</style>
The built HTML shows everything arriving where it should:
<div class="alert alert--success spaced" role="status"
data-astro-cid-lcdefpme="true" data-astro-cid-fv5igk4s>Saved.</div>
<div class="alert alert--danger alert--dismissible spaced compact" role="status" id="err"
data-astro-cid-lcdefpme="true" data-astro-cid-fv5igk4s>Failed.</div>
Each div carries two scope attributes: fv5igk4s from the alert and lcdefpme from the page. That's why the page's .spaced[data-astro-cid-lcdefpme] rule matches an element that lives inside another component.
class:list on a Component
Notice the second alert uses class:list directly on the component. Astro resolves it before passing it down, so the child receives a plain class string. A test component that printed its props received:
{ "data-astro-cid-lcdefpme": true, "class": "x y" }
for class:list={["x", { y: true, z: false }]}. The child doesn't need to know whether the parent used class or class:list.
What Happens Without ...rest
If you leave out {...rest} and only apply className, the class still appears on the element, but the parent's scope attribute doesn't. With the default scoping strategy, the parent's .spaced rule then silently fails to match. If a class you pass in "does nothing", check the child for a missing spread first.
If your project sets scopedStyleStrategy to 'class' or 'where', the scope marker travels as a class name, so it arrives through className and the spread isn't strictly needed for styling. It's still good practice for forwarding other attributes.
Typing the Props
Extending HTMLAttributes<"div"> from astro/types gives you autocomplete for every valid div attribute, including class and class:list, and keeps ...rest correctly typed. Literal union types for variants catch mistakes at check time. Passing tone="warning" produced this from npx astro check:
error ts(2322): Type '"warning"' is not assignable to type '"info" | "success" | "danger" | undefined'.
Match the element type to the root element: HTMLAttributes<"a"> for a link component, HTMLAttributes<"button"> for a button. More patterns are covered in the post on TypeScript props in Astro.
Variants and Conditional Classes
class:list really pays off when a component has several independent options:
---
// src/components/Button.astro
import type { HTMLAttributes } from "astro/types";
interface Props extends HTMLAttributes<"button"> {
variant?: "primary" | "secondary";
size?: "sm" | "md" | "lg";
block?: boolean;
loading?: boolean;
}
const {
variant = "primary",
size = "md",
block = false,
loading = false,
class: className,
...rest
} = Astro.props;
---
<button
class:list={[
"btn",
`btn--${variant}`,
`btn--${size}`,
{ "btn--block": block, "is-loading": loading },
className,
]}
disabled={loading}
{...rest}
>
<slot />
</button>
A few habits keep this readable:
- Base class first, parent's class last: it reads naturally, and makes it easy to scan what the parent added.
- Template strings for enum-like props:
`btn--${size}`is fine when every possible value has a matching rule. - Objects for booleans:
{ "is-loading": loading }is clearer than a ternary.
Using class:list with Tailwind
class:list is just as useful with utility classes:
---
// src/components/Badge.astro
const { tone = "neutral", class: className } = Astro.props;
---
<span
class:list={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
tone === "neutral" && "bg-slate-100 text-slate-700",
tone === "success" && "bg-green-100 text-green-800",
className,
]}
>
<slot />
</span>
Expressions like tone === "success" && "..." evaluate to false when the condition fails, and class:list drops them. Tailwind classes are global, so the parent's classes work without the scope attribute; you can omit ...rest here if you don't need other attributes. The setup is covered in the post on Tailwind CSS in Astro.
When the parent passes a class that conflicts with a default, such as px-4 against px-2, both end up on the element. With utilities, which rule wins depends on the order of rules in Tailwind's stylesheet, not the order in the attribute. Either avoid overlapping defaults or use a helper like tailwind-merge to resolve conflicts.
Things I Noticed While Testing
A couple of behaviours are worth knowing about, because older articles describe them differently:
- Duplicates are kept:
class:list={["a", "a"]}renderedclass="a a". It doesn't deduplicate, so don't rely on it to clean up class strings. - Sets aren't supported as the top-level value:
class:list={new Set(["a", "b"])}produced noclassattribute at all. Pass an array, for example[...mySet]. - Whitespace inside strings is passed through: a string like
" b "is added as-is. Browsers ignore extra spaces inclass, so this is cosmetic.
When to Use Classes vs Variables
class:list is best when a component switches between a known set of looks. When the value is continuous, like a percentage or an arbitrary colour, CSS custom properties are a better fit than generating classes; see the post on define:vars in Astro.
| Need | Use |
|---|---|
| A few fixed variants | class:list with modifier classes |
| Spacing or position set by the parent | Parent class passed through class |
| Arbitrary values from data | define:vars or a style attribute |
| Utility-first styling | class:list with Tailwind classes |
FAQ: class:list in Astro
It's a template directive that turns an array of strings, objects and nested arrays into a single class attribute. Falsy values are skipped and object keys are included only when their values are truthy.
The component must read the class prop, apply it to its root element and spread the remaining props. The spread carries the parent's data-astro-cid attribute, which the parent's scoped CSS needs in order to match.
Yes. Astro evaluates it and passes the result to the child as a normal class prop string, so the child handles it exactly like class.
No. In my tests on Astro 7.4.2, repeated strings were kept in the output. Duplicates are harmless in the browser, but don't count on the directive to deduplicate them.
It works: in my test, a static class of "static" on the same element as a class:list array containing "dyn" rendered class="static dyn". Still, putting static classes inside the class:list array keeps all the class logic in one place.
No. It's an Astro template directive and only works in .astro files. In React you'd use clsx or a similar helper with className, and Vue has its own array and object class syntax.
Conclusion
class:list makes conditional classes in Astro readable: strings, objects and arrays go in, and a clean class string comes out. Combined with a class prop and a ...rest spread, it also solves the common problem of styling a child component from its parent, because the parent's class and its scope attribute both land on the child's root element.
Type your props with HTMLAttributes, keep the parent's class last in the list, and remember that the spread is what makes scoped parent styles match. With that pattern in your base components, every part of your site can adjust spacing, layout and variants without reaching into another component's CSS.


