Astro tip: Stop hardcoding theme logic into components - use Data Attributes

October 22, 2026 · 8 min read

If you've ever built a site that pulls rich text from a headless CMS, you've probably hit the moment. The moment when your beautiful, perfectly-typed Portable Text or HTML renderer suddenly needs to render light text on a dark background section, and your first instinct is to pass a theme prop, then fork your component, then wonder how this happened to you.
There's a better way. And it doesn't require prop drilling, context providers, or any runtime JavaScript at all. Just data-* attributes and a little CSS selector magic.
In this tutorial, we'll walk through a pattern for building CMS-driven components in Astro that respond to their rendering context using data-* attributes on wrapper elements. The examples use Sanity's Portable Text, but the concept applies to any rich text output from any headless CMS.
The problem with "theme as a prop"
Let's say your CMS returns a rich text block. You render it with a component, call it <PortableText />, which internally maps each block type to an Astro component. Clean enough.
Now your designer comes to you with a new requirement: some sections have a dark background, and the text inside them needs to be lighter. Maybe text-beige-500 instead of text-charcoal-800.
A quick first thought: Add a variant prop.
<PortableText variant="light" />You update <PortableText />, pass the variant down to <Paragraph />, down to <Heading />, down to <ListItem /> and suddenly you've created a prop waterfall that touches every single component in your renderer tree. Then your CMS editor creates a nested rich text block inside a callout component, and the variant doesn't pass through because you forgot to wire it up there.
Sound familiar? This is the kind of thing that starts as a 20-minute ticket and turns into a refactor that ruins your Thursday.
A better mental model: let the DOM carry the context
HTML has always had a way to attach arbitrary metadata to elements: data-* attributes. You probably already use them for JavaScript hooks. But you can also use them as CSS selectors.
Modern CSS (and utility frameworks like Tailwind) support attribute selectors like:
[data-variant="light"] .paragraph {
color: var(--beige-500);
}Or, using the in-data-[] syntax supported by Tailwind, you can scope styles based on an ancestor's data attribute, without touching the component itself.
The insight is simple: instead of passing context down through props, you set it once on a wrapper element and let CSS propagate it downward automatically.
This isn't a new idea; it's basically how CSS theming has worked for decades with class names. The twist here is using data-* attributes because they're more semantically explicit than utility classes; they don't pollute your class attribute with logic, and they compose beautifully with tools like cva (Class Variance Authority).
Your toolbelt for this pattern
Before we get into the implementation, here's what we're working with:
Tool | Role |
|---|---|
Astro | Component framework (server-first, great for CMS-driven sites) |
Sanity Portable Text | Rich text renderer (our CMS of choice in these examples) |
| Variant-aware className composition |
| Merges class strings cleanly (usually |
Tailwind CSS | Utility-first CSS with attribute selector support |
If you're not using Tailwind or cva, don't worry; the principle works just as well with vanilla CSS or any CSS-in-JS setup. The data attribute is the idea; the rest is implementation detail.
Step 1: Build a variant-aware base component
Let's start with Paragraph.astro, a simple text component that needs to adapt to both light and dark contexts.
---
import { cva } from "class-variance-authority";
import { cn } from "@/utils/cn";
const paragraphVariants = cva(
[
"font-sans leading-10",
"in-data-[variant=light]:text-beige-500 data-[variant=light]:text-beige-500",
"text-charcoal-800",
],
{
variants: {
size: {
lg: "text-xl",
md: "text-base",
sm: "text-sm",
},
},
defaultVariants: {
size: "md",
},
},
);
const { as = "p", size = "md", class: className, ...props } = Astro.props;
const Tag = as;
---
<Tag class={cn(paragraphVariants({ size }), className)} {...props}>
<slot />
</Tag>There are two things worth calling out here.
First, notice the two selectors that handle the light variant:
data-[variant=light]:text-beige-500this applies when the attribute is on the element itselfin-data-[variant=light]:text-beige-500this applies when the attribute is on an ancestor element
This dual approach means the component works whether you're wrapping it from the outside (like inside a <PortableText /> block) or using it standalone.
Second, the default text-charcoal-800 acts as a fallback. No variant? Dark text. Clean and obvious.
Step 2: Wire it into your portable text renderer
Now let's connect this to a Portable Text renderer. In Sanity, you map block types to components. Here's a minimal example:
---
import { PortableText as PortableTextComponent } from "astro-portabletext";
import Heading from "@/components/Heading.astro";
import Paragraph from "@/components/Paragraph.astro";
const { value } = Astro.props;
---
<PortableTextComponent
value={value}
components={{
block: {
h1: Heading,
h2: Heading,
h3: Heading,
h4: Heading,
h5: Heading,
h6: Heading,
normal: Paragraph,
}
}}
/>Notice: no variant prop anywhere here. The components don't need to know about theming. They just render.
Step 3: Control rendering from the wrapper
Here's where the pattern pays off. In whatever page or section component renders your CMS content, you just wrap it:
<!-- Dark section: override to light text -->
<div data-variant="light">
<PortableText value={block.body} />
</div>
<!-- Default section: no wrapper needed -->
<PortableText value={block.body} />That's it. The data-variant="light" on the outer div automatically cascades down through the DOM tree, triggering the in-data-[variant=light]: selectors in every child component that has them defined.
No prop drilling. No context API. No re-renders. It just works, at zero runtime cost because it's pure CSS.
Step 4: Using it without a CMS
Because the component also handles the data-variant attribute directly on itself, you can use it outside of Portable Text too:
<!-- Standalone usage, light variant -->
<Paragraph data-variant="light">This is a paragraph text</Paragraph>
<!-- Or nested usage with a shared wrapper -->
<div data-variant="light">
<Paragraph>First paragraph</Paragraph>
<Paragraph>Second paragraph</Paragraph>
<Heading as="h2">A heading</Heading>
</div>Both approaches trigger the light variant correctly. The component is agnostic about how the attribute gets to it; whether it's on itself or on a parent, it responds the same way.
Common pitfalls (and how to avoid them)
1. Forgetting the dual selector
If you only add in-data-[variant=light]:text-beige-500 and forget data-[variant=light]:text-beige-500, standalone usage like <Paragraph data-variant="light"> won't work. Always add both; it's two classes, not a crime.
2. Tailwind purging your selectors
If you're generating class names dynamically (e.g., constructing data-[variant=${someVar}] as a string), Tailwind's content scanner won't detect them at build time, and they'll be stripped. Keep your attribute selectors as literal strings in your source code.
3. Specificity surprises
Data attribute selectors have the same specificity as class selectors in CSS. If you have a className prop being passed in that conflicts, it might win or lose depending on order. Use cn() properly; make sure overrides come last.
4. Over-attributing
This pattern is great for contextual rendering concerns, theming, density, alignment. It's less great for things that are truly component-specific (like a count or href). Don't replace props entirely; use data attributes for ambient context and props for specific configuration.
5. SSR vs. client rendering
Because this is CSS-only, it works perfectly in SSR and static generation, which is exactly what Astro is built for. If you're using a framework that relies heavily on client-side rendering, make sure the data attributes are in the initial HTML, not injected after hydration.
Why this works so well with Astro specifically
Astro has a strong bias toward server-rendering and zero JavaScript by default. Props-as-context patterns often require runtime JS, context APIs, stores, or state management. Data attributes don't.
Since Astro renders to static HTML (or server HTML), the data-* attributes are in the markup from the start. There's no hydration delay, no flash of unstyled content, no mismatch. The CSS selectors just work, immediately, on whatever environment serves the page.
This also plays nicely with Astro's islands architecture. If part of your page is a hydrated React or Svelte island, the data attributes on ancestor DOM elements are still visible to CSS, even if that island doesn't know about them.
Conclusion
Here's the takeaway: your CMS components don't need to know where they're being rendered or what the surrounding context looks like. That's not their job. Their job is to render content correctly given the signals available to them.
data-* attributes let you declare those signals once, at the wrapper level, and have them cascade automatically through your component tree via CSS. No prop drilling, no context providers, no runtime overhead.
The pattern is straightforward to implement with cva and Tailwind, scales cleanly as you add more contextual attributes, and works beautifully in Astro's server-first rendering model.
Next time you find yourself passing theme="dark" down through five layers of components, try a data-variant="dark" wrapper instead. Your future self, the one doing that Thursday refactor, will thank you.
FAQs
Does this pattern work with frameworks other than Astro, like Next.js or Nuxt?
Absolutely. The data attribute + CSS approach is framework-agnostic; it's just HTML and CSS. In Next.js or Nuxt, you'd apply data-variant="light" to a server-rendered wrapper element the same way. The cva library also works in React and Vue components. The main nuance is that in-data-[] is a Tailwind syntax feature - if you're using vanilla CSS or CSS Modules, you'd write the ancestor selector manually: [data-variant="light"] .paragraph { color: ... }. Same effect, slightly more verbose.
Can I use this with Tailwind's dark: mode instead?
You can, but they solve slightly different problems. Tailwind's dark: mode is typically driven by a class on <html> (or a media query) and is global; it's for system-wide dark mode. The data attribute pattern described here is for component-level contextual variants that might exist simultaneously on the same page. You could have a light-mode page with a dark hero section and a light feature section, each with different text variants, all controlled by data attributes; that's not something dark: handles well out of the box.
What happens if I have deeply nested components where the ancestor data attribute is very far up the DOM tree?
CSS attribute selectors work across any descendant depth [data-variant="light"] .paragraph matches no matter how many layers of nesting are between the attribute and the target element. The in-data-[] Tailwind syntax does the same. There's no depth limit. The one edge case to watch is if you have an intermediate element that resets the variant, for example, a data-variant="dark" wrapper nested inside a data-variant="light" parent. In that case, the more specific/closer ancestor wins if you structure your selectors appropriately (using :scope or careful specificity management).
How do I expose the variant control to CMS editors in Sanity?
Great question. The simplest approach is to add a variant field to your section or block schema in Sanity, something like a string field with options ["light", "dark"]. Then, in your page builder component, you read that field value and apply it to the wrapper element: <div data-variant={section.variant ?? "dark"}>. The CMS editors toggle the variant through the Sanity Studio UI; your components respond automatically. No custom front-end toggle logic needed.
Is there a performance cost to using CSS attribute selectors extensively?
Negligible in practice. CSS attribute selector performance is sometimes cited as slower than class selectors, but the difference is only measurable in extremely large DOMs (thousands of nodes), and modern browser rendering engines have largely optimized this away. For a typical CMS-driven page with dozens to a few hundred DOM nodes, you will not notice any difference. The CSS is evaluated once during layout; it doesn't run on every render cycle the way JavaScript-based theming approaches do, which actually makes it faster than runtime JS alternatives in most real-world scenarios.
Technologies

Stay up-to-date
Be updated with all news, products and tips we share!
On this page
- The problem with "theme as a prop"
- A better mental model: let the DOM carry the context
- Your toolbelt for this pattern
- Step 1: Build a variant-aware base component
- Step 2: Wire it into your portable text renderer
- Step 3: Control rendering from the wrapper
- Step 4: Using it without a CMS
- Common pitfalls (and how to avoid them)
- Why this works so well with Astro specifically
- Conclusion
- FAQs
