Astro styling starts with scoped CSS: a <style> block inside an .astro component only applies to that component. On top of that you can import global stylesheets, use Tailwind CSS, pass values into CSS with define:vars, load fonts through the built-in Fonts API, and use Sass or other preprocessors with no extra config. Astro bundles and minifies all of it, and inlines small stylesheets automatically.
This guide covers each option in Astro 7 and how to combine them into a system that makes a site easy to rebrand.
Key Takeaways
<style>in a component is scoped by default. Use:global()or<style is:global>for the exceptions.- Put site-wide resets and tokens in one global stylesheet imported by your base layout.
npx astro add tailwindsets up Tailwind CSS through its Vite plugin and creates a global CSS file with@import "tailwindcss".- CSS custom properties are the simplest design-token system and work with every option here.
- The Fonts API (
fontsinastro.config.mjsplus the<Font />component) downloads, self-hosts and preloads web fonts for you.
Which approach to choose
| Approach | Best for | Ships JS? |
|---|---|---|
Scoped <style> |
Component-specific layout and visuals | No |
| Global stylesheet | Resets, typography, tokens | No |
| Tailwind CSS | Teams that prefer utility classes | No |
define:vars |
Values from props or frontmatter | No |
| CSS Modules / Sass | Existing codebases, nested syntax | No |
| CSS-in-JS in islands | Only inside framework components | Sometimes |
Most production Astro sites combine the first two or three. Komal themes, for example, use Tailwind CSS for layout and utilities with a small global stylesheet for tokens and base typography.
Scoped styles
Write CSS inside a component and Astro scopes it:
---
// src/components/Card.astro
---
<article class="card">
<h3><slot name="title" /></h3>
<slot />
</article>
<style>
.card {
padding: 1.5rem;
border: 1px solid var(--color-border);
border-radius: 0.75rem;
}
h3 { margin-block: 0 0.5rem; }
</style>
At build time Astro adds a data-astro-cid-* attribute (or class, depending on scopedStyleStrategy) to the elements this component renders and rewrites selectors to include it. The h3 rule above can’t leak into any other component.
With the default scopedStyleStrategy: 'attribute', scoping adds one attribute selector, so a scoped rule beats an equally specific global rule. If you want global utilities to win instead, set scopedStyleStrategy: 'where', which scopes with :where() and adds no specificity.
Styling slotted content and children
Scoping only matches elements written in the component’s own template. Content passed in through <slot />, or HTML from set:html or Markdown, is not matched. Use :global() around the part of the selector that targets it:
<div class="prose"><slot /></div>
<style>
.prose :global(h2) { margin-top: 2.5rem; }
.prose :global(a) { color: var(--color-accent); }
</style>
Keeping a scoped parent (.prose) in front of :global() stops the rule leaking to the rest of the page.
Passing a class into a child component
A parent’s scoped class won’t style a child component’s root unless the child accepts it. Destructure class and apply it:
---
// src/components/Button.astro
const { class: className, ...rest } = Astro.props;
---
<button class:list={['btn', className]} {...rest}><slot /></button>
Global styles
For resets, base typography and tokens, create a stylesheet and import it once in your base layout:
---
// src/layouts/BaseLayout.astro
import '../styles/global.css';
---
CSS imported in frontmatter is bundled and added to every page that uses the layout. You can also write <style is:global> inside a component, which is convenient for a few rules but harder to find later. The scoped vs global section of the Astro docs covers ordering when both apply.
Files in public/ are served untouched and are not processed. Put CSS there only if you want it linked manually and never bundled.
Tailwind CSS in Astro
Add Tailwind with one command:
npx astro add tailwind
This installs tailwindcss and @tailwindcss/vite, registers the Vite plugin in astro.config.mjs, and creates src/styles/global.css:
/* src/styles/global.css */
@import "tailwindcss";
Import that file in your layout, and utility classes work in every .astro, .mdx and framework component:
<a href="/themes/" class="inline-flex items-center rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">
Browse themes
</a>
Tailwind’s current version is configured in CSS with @theme, so your tokens and Tailwind’s scale live in the same file:
@import "tailwindcss";
@theme {
--color-brand: #2563eb;
--font-display: "Inter", system-ui, sans-serif;
}
That makes bg-brand and font-display available as utilities. Projects started on Tailwind CSS 3 use a tailwind.config file instead. They keep working, and Tailwind publishes an upgrade tool when you decide to move. The Tailwind Astro guide has the current setup.
Use class:list to combine conditional utilities without string concatenation:
<li class:list={['px-3 py-2', { 'font-bold text-brand': isActive }]}>...</li>
Design tokens with CSS custom properties
Whatever you choose above, keep design decisions in one place. CSS custom properties work everywhere: scoped styles, global CSS, Tailwind and framework components.
/* src/styles/tokens.css */
:root {
--color-bg: #ffffff;
--color-text: #14140f;
--color-muted: #5b5b55;
--color-accent: #2563eb;
--color-border: #e5e4df;
--font-body: system-ui, sans-serif;
--space-section: clamp(4rem, 8vw, 8rem);
--radius: 0.75rem;
}
Components reference tokens, never raw values:
.card { background: var(--color-bg); border-radius: var(--radius); }
A rebrand then means editing one file, which is how a theme can be restyled in an afternoon. The styling docs for Komal themes show where each theme keeps its tokens.
define:vars for runtime values
When a value comes from props or frontmatter, define:vars turns it into scoped CSS variables:
---
// src/components/Badge.astro
const { color = '#2563eb' } = Astro.props;
---
<span class="badge"><slot /></span>
<style define:vars={{ color }}>
.badge { background: var(--color); color: white; }
</style>
Each instance can have a different colour without inline style attributes everywhere. The define:vars tip covers the gotchas, including how it affects script bundling.
Fonts with the Fonts API
Since Astro 6, fonts have a built-in API. Declare families in config:
// astro.config.mjs
import { defineConfig, fontProviders } from 'astro/config';
export default defineConfig({
fonts: [
{
provider: fontProviders.google(),
name: 'Inter',
cssVariable: '--font-inter',
weights: [400, 600],
styles: ['normal'],
},
],
});
Then add the <Font /> component to your layout’s <head>:
---
import { Font } from 'astro:assets';
---
<head>
<Font cssVariable="--font-inter" preload />
</head>
<style is:global>
body { font-family: var(--font-inter); }
</style>
Astro downloads the files at build time, serves them from your own domain, generates @font-face rules and optimised fallbacks, and adds preload links. Visitors make no request to a third-party font CDN. Local font files are supported with the local provider. The Astro fonts guide lists every provider.
Sass, Less and Stylus
Install the preprocessor and use it with lang:
npm install -D sass
<style lang="scss">
$gap: 1rem;
.grid { display: grid; gap: $gap; }
</style>
No config is required; Vite handles it. Native CSS now has nesting and custom properties, so many projects don’t need Sass at all. If you already have a Sass codebase, it carries over as is. See the Sass setup tip for partials and global variables.
Dark mode without a flash
Two parts: tokens that change under a data-theme attribute, and an inline script that sets the attribute before the page paints.
/* src/styles/tokens.css */
:root { --color-bg: #ffffff; --color-text: #14140f; }
:root[data-theme='dark'] { --color-bg: #0f0f0d; --color-text: #f3f1ea; }
---
// in BaseLayout.astro <head>
---
<script is:inline>
const stored = localStorage.getItem('theme');
const prefersDark = matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = stored ?? (prefersDark ? 'dark' : 'light');
</script>
is:inline matters here. A processed script is a deferred module and runs after first paint, which causes the flash. An inline script in <head> runs before the body renders.
A toggle button then flips dataset.theme and writes localStorage. If you use <ClientRouter /> for view transitions, rerun the logic on the astro:after-swap event so the attribute survives navigation.
Every Komal theme ships with its tokens, fonts and Tailwind configuration done. Browse Astro themes and change the colours in one file instead of building the system yourself.
Typography for Markdown content
Markdown renders plain elements (h2, p, ul, blockquote, pre) with no classes, so long-form pages need a typography layer. You can use Tailwind’s typography plugin and wrap content in prose, or write a small stylesheet yourself:
---
// src/layouts/BlogPost.astro
---
<article class="prose">
<slot />
</article>
<style>
.prose { max-width: 68ch; font-size: 1.125rem; line-height: 1.7; }
.prose :global(h2) { margin: 2.5em 0 0.75em; font-size: 1.6rem; line-height: 1.25; }
.prose :global(h3) { margin: 2em 0 0.5em; font-size: 1.25rem; }
.prose :global(p), .prose :global(ul), .prose :global(ol) { margin: 0 0 1.25em; }
.prose :global(a) { color: var(--color-accent); text-underline-offset: 0.2em; }
.prose :global(blockquote) { border-left: 3px solid var(--color-border); padding-left: 1rem; color: var(--color-muted); }
.prose :global(pre) { padding: 1rem 1.25rem; border-radius: var(--radius); overflow-x: auto; font-size: 0.9rem; }
.prose :global(img) { border-radius: var(--radius); }
</style>
A line length around 60 to 75 characters (68ch here) is easier to read than full-width text. Code blocks are highlighted by Shiki at build time, with no client JavaScript; choose a theme with markdown.shikiConfig.theme in astro.config.mjs, or pass themes: { light, dark } to switch with your dark mode.
Worked example: rebranding a site in one file
Here’s how the pieces fit when a client asks for new colours and fonts. Assume a Tailwind project with tokens in src/styles/global.css:
/* src/styles/global.css */
@import "tailwindcss";
@theme {
--color-brand: #2563eb;
--color-brand-ink: #ffffff;
--color-surface: #ffffff;
--color-ink: #14140f;
--color-muted: #5b5b55;
--radius-card: 0.75rem;
--font-display: var(--font-inter);
--font-body: var(--font-inter);
}
:root[data-theme='dark'] {
--color-surface: #0f0f0d;
--color-ink: #f3f1ea;
--color-muted: #a3a29b;
}
Components only ever use those names:
<a href="/contact/" class="rounded-card bg-brand px-5 py-3 font-display text-brand-ink">
Start a project
</a>
The rebrand is then three edits:
- Colours. Change
--color-brandand--color-brand-inkin@theme. Every button, link and accent follows. - Fonts. Swap the family in the
fontsconfig (for examplename: 'Fraunces'withcssVariable: '--font-fraunces') and point--font-displayat it. - Shape. Change
--radius-cardfrom0.75remto0for a sharper look.
No component files change. If you find yourself editing twenty components to change a colour, the design values have leaked out of the token layer. Fix that first; every future change gets cheaper.
Container queries for reusable components
Media queries respond to the viewport. A card component placed in a sidebar and in a full-width grid needs to respond to its container instead. Native CSS container queries work in all current browsers and fit Astro’s scoped styles well:
<div class="card-wrap"><article class="card"><slot /></article></div>
<style>
.card-wrap { container-type: inline-size; }
.card { display: grid; gap: 1rem; }
@container (min-width: 32rem) {
.card { grid-template-columns: 12rem 1fr; }
}
</style>
The card switches to a side-by-side layout whenever it has room, wherever it’s placed. Tailwind supports the same idea with @container and @md: style variants.
Common styling mistakes
Fighting scoped styles with !important. If a scoped rule won’t apply to slotted or set:html content, the fix is :global() around the child selector, not !important.
Styling Markdown output with scoped selectors. <Content /> renders HTML that isn’t part of your component’s template. Wrap it in a .prose element and target .prose :global(h2).
Putting CSS in public/ expecting it to be processed. Files in public/ are served as-is: no bundling, no minification, no Tailwind. Keep stylesheets in src/.
Importing a stylesheet from many components. Each import is fine on its own, but repeated global imports make cascade order hard to predict. Import global CSS once, in the base layout.
Hard-coded colours in components. color: #2563eb in twelve files makes a rebrand a search-and-replace job. Use tokens.
Performance notes
- Astro inlines stylesheets smaller than 4 KB by default (
build.inlineStylesheets: 'auto') and links larger ones. Set it to'always'for single-page sites or'never'for heavy caching. - Scoped styles only load on pages that render the component, so unused component CSS never ships.
- Avoid importing a large CSS framework globally when you use a fraction of it.
The Astro performance guide covers CSS delivery alongside images and scripts.
FAQ
Are Astro styles scoped by default?
Yes. A <style> block in an .astro component applies only to elements in that component’s template. Use is:global or :global() to opt out.
How do I add global CSS in Astro?
Create a CSS file, for example src/styles/global.css, and import it in the frontmatter of your base layout. Every page using that layout gets the styles.
Does Tailwind CSS work with Astro 7?
Yes. Run npx astro add tailwind to install Tailwind with its Vite plugin and create a global stylesheet that imports it.
Can I use CSS Modules in Astro?
Yes. Name the file *.module.css, import it as an object in frontmatter, and use styles.className in the template. Scoped <style> blocks usually make modules unnecessary in .astro files.
How do I style Markdown content in Astro?
Wrap the rendered <Content /> in an element with a class and target its children with :global(), for example .prose :global(h2). Tailwind users can use the typography plugin’s prose classes.