Scoped Styles

Scoped styles namespace a component's CSS at build time, keeping its selectors and animations from leaking into the rest of the page.

Class Scoping

Write ordinary class names. Bascik prefixes every class a component's CSS declares with the component's name, in the CSS and in the HTML, so two components can both use .card and never style each other.

Two components sit side by side below. Their markup is identical: both use .card, .tag, and .title, and both style a bare p. Each keeps its own look, and neither stylesheet reaches the other card or the page around them. Open Source and Output to compare the class names.

plan-card + alert-card
plan-card

Pro plan

Same class names as the card beside it.

alert-card

Disk almost full

Same class names, its own styles.

html
<plan-card></plan-card> <alert-card></alert-card>
html
<!-- src/components/plan-card/plan-card.html -->
<article class="card">
  <span class="tag">plan-card</span>
  <h3 class="title">Pro plan</h3>
  <p>Same class names as the card beside it.</p>
</article>

<!-- src/components/alert-card/alert-card.html -->
<article class="card">
  <span class="tag">alert-card</span>
  <h3 class="title">Disk almost full</h3>
  <p>Same class names, its own styles.</p>
</article>
css
/* src/components/plan-card/plan-card.css */
.card {
  padding: 24px;
  border-radius: 16px;
  background: #1d2612;
  border: 1px solid rgba(211, 255, 141, 0.35);
}

.tag {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 999px;
  background: #d3ff8d;
  color: #16210a;
  font-size: 0.72rem;
  font-weight: 700;
}

.title {
  margin: 12px 0 6px;
  font-size: 1.3rem;
  color: #d3ff8d;
}

p {
  margin: 0;
  color: #c9d6b5;
}

/* src/components/alert-card/alert-card.css */
.card {
  padding: 20px;
  background: #2a1712;
  border-left: 4px solid #ff8a5c;
}

.tag {
  display: inline-block;
  padding: 2px 8px;
  border: 1px solid #ff8a5c;
  color: #ff8a5c;
  font-family: ui-monospace, monospace;
  font-size: 0.7rem;
}

.title {
  margin: 12px 0 6px;
  font-family: ui-monospace, monospace;
  font-size: 0.95rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #ff8a5c;
}

p {
  margin: 0;
  color: #e6c4b8;
  font-style: italic;
}
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.
html
<article class="bascik__plan-card__card">
  <span class="bascik__plan-card__tag">plan-card</span>
  <h3 class="bascik__plan-card__title">Pro plan</h3>
  <p class="b_el_plan-card_p">Same class names as the card beside it.</p>
</article>

<article class="bascik__alert-card__card">
  <span class="bascik__alert-card__tag">alert-card</span>
  <h3 class="bascik__alert-card__title">Disk almost full</h3>
  <p class="b_el_alert-card_p">Same class names, its own styles.</p>
</article>
css
/* Hoisted into the page <head>, one block per component */
.bascik__plan-card__card {
  padding: 24px;
  border-radius: 16px;
  background: #1d2612;
  border: 1px solid rgba(211, 255, 141, 0.35);
}

.bascik__plan-card__tag {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 999px;
  background: #d3ff8d;
  color: #16210a;
  font-size: 0.72rem;
  font-weight: 700;
}

.bascik__plan-card__title {
  margin: 12px 0 6px;
  font-size: 1.3rem;
  color: #d3ff8d;
}

.b_el_plan-card_p {
  margin: 0;
  color: #c9d6b5;
}

.bascik__alert-card__card {
  padding: 20px;
  background: #2a1712;
  border-left: 4px solid #ff8a5c;
}

.bascik__alert-card__tag {
  display: inline-block;
  padding: 2px 8px;
  border: 1px solid #ff8a5c;
  color: #ff8a5c;
  font-family: ui-monospace, monospace;
  font-size: 0.7rem;
}

.bascik__alert-card__title {
  margin: 12px 0 6px;
  font-family: ui-monospace, monospace;
  font-size: 0.95rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #ff8a5c;
}

.b_el_alert-card_p {
  margin: 0;
  color: #e6c4b8;
  font-style: italic;
}

Element Selector Scoping

A bare element selector such as p { } styles only the component's own paragraphs. Bascik turns it into a generated class and adds that class to the matching elements in the component:

html
<!-- src/components/frother-status.html -->
<style>
  p {
    margin: 0;
  }
</style>
<p>Heating milk</p>
html
<!-- dist/index.html -->
<style>
  .b_el_frother-status_p {
    margin: 0;
  }
</style>
<p class="b_el_frother-status_p">Heating milk</p>

Isolation guarantee: Element styles only affect elements inside the component. A p {} rule in my-comp.css will never affect <p> tags on the page or in other components.

Global Utility & Accessibility Classes

Class names in a component's HTML template are only scoped if they are declared in the component's stylesheet (.css file or inline <style> block).

If a class name is not defined in the component's CSS, Bascik leaves it unscoped. This allows global utility classes (such as skip-link, flex, hidden, or global design tokens) defined in your global stylesheet (e.g. src/css/styles.css) to pass through and match normally across any component.

This applies to every component, including one with no stylesheet at all. A header or footer that relies entirely on a global stylesheet needs no .css file and no placeholder comment: none of its classes are defined by the component, so none are scoped. The same holds for class names a component's script passes to classList, querySelector, or className.

A component without a stylesheet has no class isolation. Scoping exists to keep a component's own rules from colliding with other components. With no rules of its own, there is nothing to isolate, so a script that queries .item in such a component matches every .item on the page. Add a stylesheet that defines the class to scope it.

html
<!-- src/components/site-nav.html -->
<a href="#main-content" class="skip-link logo">Skip to main content</a>
css
/* src/components/site-nav.css */
/* .logo is declared locally → Bascik scopes it */
.logo {
  display: inline-flex;
  align-items: center;
}
css
/* src/css/styles.css (global stylesheet) */
/* .skip-link is NOT in site-nav.css → Bascik leaves it unscoped */
.skip-link {
  position: absolute;
  top: -100px;
}
.skip-link:focus {
  top: 16px;
}
html
<!-- dist/index.html (compiled output) -->
<a href="#main-content" class="skip-link bascik__site-nav__logo">
  Skip to main content
</a>
Declared in the component, scoped. Declared elsewhere, left alone.

site-nav.css declares .logo, but not .skip-link

You write

html
<a class="skip-link logo"></a>

Bascik emits

html
<a class="skip-link bascik__site-nav__logo"></a>

CSS Custom Properties

--var-name declarations in a component's CSS are automatically scoped. All var(--var-name) references within the same file are updated to match, so custom properties stay isolated to their component. The same names are renamed inside style attributes in the component's template, so a per-element override such as <li style="--i: 2"> still reaches the scoped var(--i). A style attribute on the component tag where a page uses it is not renamed: it belongs to the page, so a property the component declares cannot be overridden from there. The same holds inside a template: a style on a custom element tag, such as a child component's tag, is passed through as written, so <child-btn style="--accent: blue"> sets the global --accent even when the parent declares an --accent of its own, and a child that reads var(--accent) without declaring it receives blue.

Only locally-declared properties are scoped. If a component uses var(--global-var) but doesn't declare --global-var in its own CSS, that reference is left untouched so it still resolves from a global stylesheet.

Custom properties follow the same rule

Declared in the component CSS

You write

css
.card {
  --signal-color: #d3ff8d;
  background: var(--signal-color);
}

Bascik emits

css
.bascik__brand-card__card {
  --bascik__brand-card__signal-color: #d3ff8d;
  background: var(--bascik__brand-card__signal-color);
}

Declared in a global stylesheet

You write

css
.card {
  background: var(--card-bg);
}

Bascik emits

css
.bascik__brand-card__card {
  background: var(--card-bg);
}

Using global design tokens in a component

Define your design tokens once in a global stylesheet, then consume them inside any component. Because the component never declares those properties locally, Bascik leaves the var() references as-is and they resolve from the global stylesheet at render time.

css
/* src/styles.css: design tokens, linked in every page <head> */
:root {
  --brand: #d3ff8d;
  --card-bg: #1e2022;
  --text-muted: #8d929e;
}
html
<!-- src/components/brand-card.html -->
<style>
  .card {
    padding: 24px 28px;
    background: var(--card-bg); /* global: Bascik leaves untouched */
    border-top: 3px solid var(--brand);
    border-radius: 10px;
  }
  .label {
    color: var(--text-muted);
  }
</style>
<div class="card">
  <p class="label" data-bascik-prop-label></p>
  <div data-bascik-slot></div>
</div>
css
/* dist/ output: class names scoped, var() refs preserved */
.bascik__brand-card__card {
  padding: 24px 28px;
  background: var(--card-bg);
  border-top: 3px solid var(--brand);
  border-radius: 10px;
}
.bascik__brand-card__label {
  color: var(--text-muted);
}

The Example Components use this pattern: each reads tokens such as var(--color-surface, ...) with a fallback, so it works with or without a token stylesheet. The Modal Dialog shows html:has() for a scroll lock, and the Popover Menu shows anchor positioning without anchor-name.

@media Support

Media queries work as written. Class names inside them are scoped like any other rule:

css
/* you write */
@media (prefers-reduced-motion: reduce) {
  .meter {
    animation: none;
  }
}

/* Bascik emits */
@media (prefers-reduced-motion: reduce) {
  .bascik__frother-status__meter {
    animation: none;
  }
}

@keyframes Scoping

Keyframe names are prefixed too, and so are the animation declarations that use them, so two components can both define pulse without colliding:

css
/* you write */
.meter {
  animation: pulse 1s infinite alternate;
}

@keyframes pulse {
  to {
    opacity: 0.4;
  }
}

/* Bascik emits */
.bascik__frother-status__meter {
  animation: bascik__frother-status__keyframe__pulse 1s infinite alternate;
}

@keyframes bascik__frother-status__keyframe__pulse {
  to {
    opacity: 0.4;
  }
}

CSS ID Selectors

CSS #id selectors are converted to scoped class selectors, and the generated class is injected onto the matching element in the HTML. This means #btn {} in a component is fully isolated, the same ID name in another component or on the page produces a completely different selector.

Each instance still gets its own id, while the generated class is shared, so one rule styles every instance:

html
<!-- you write -->
<style>
  #heat {
    border-radius: 999px;
  }
</style>
<span id="heat"></span>

<!-- Bascik emits, for two <frother-status /> tags -->
<style>
  .b_id_frother-status_heat {
    border-radius: 999px;
  }
</style>
<span
  class="b_id_frother-status_heat"
  id="bascik__frother-status__2f1cd31f__heat"
></span>
<span
  class="b_id_frother-status_heat"
  id="bascik__frother-status__2fecad85__heat"
></span>

Specificity note: Converting #id to a class drops specificity from (0,1,0,0) to (0,0,1,0). [id] and [id="…"] attribute-selector forms are stripped at compile time, use a class selector instead.

Selector and Animation Lab

This component puts everything above together: a class, an ID selector, bare h3 and p selectors, a local custom property, a media query, and a keyframe animation. Inspect Source and Output to see Bascik rewrite them while the meter sweeps in the preview.

src/components/scope-lab/

Scoped selectors are active

Bare element selectors, keyframes, and custom properties stay inside this component.

html
<scope-lab />
html
<section class="lab">
  <div>
    <h3>Scoped selectors are active</h3>
    <p>
      Bare element selectors, keyframes, and custom properties stay inside this
      component.
    </p>
  </div>
  <span class="meter" id="signal" aria-hidden="true"></span>
</section>
css
.lab {
  --signal-color: #d3ff8d;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.meter {
  height: 4px;
  background: var(--signal-color);
  transform-origin: left center;
  animation: scope-sweep 1.6s ease-in-out infinite alternate;
}

#signal {
  border-radius: 999px;
}

h3 {
  color: #f0f1f2;
}
p {
  color: #8d929e;
}

@keyframes scope-sweep {
  from {
    transform: scaleX(0.15);
  }
  to {
    transform: scaleX(1);
  }
}

@media (prefers-reduced-motion: reduce) {
  .meter {
    animation: none;
  }
}
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.
html
<section class="bascik__scope-lab__lab">
  <div>
    <h3 class="b_el_scope-lab_h3">Scoped selectors are active</h3>
    <p class="b_el_scope-lab_p">
      Bare element selectors, keyframes, and custom properties stay inside this
      component.
    </p>
  </div>
  <span
    class="bascik__scope-lab__meter b_id_scope-lab_signal"
    id="bascik__scope-lab__a1b2__signal"
    aria-hidden="true"
  ></span>
</section>
css
.bascik__scope-lab__lab {
  --bascik__scope-lab__signal-color: #d3ff8d;
}

.bascik__scope-lab__meter {
  background: var(--bascik__scope-lab__signal-color);
  animation: bascik__scope-lab__keyframe__scope-sweep 1.6s ease-in-out infinite
    alternate;
}

.b_id_scope-lab_signal {
  border-radius: 999px;
}

.b_el_scope-lab_h3 {
  color: #f0f1f2;
}
.b_el_scope-lab_p {
  color: #8d929e;
}

@keyframes bascik__scope-lab__keyframe__scope-sweep {
  from {
    transform: scaleX(0.15);
  }
  to {
    transform: scaleX(1);
  }
}

@media (prefers-reduced-motion: reduce) {
  .bascik__scope-lab__meter {
    animation: none;
  }
}

Style Blocks and Companion Files

Component CSS can live in a <style> block inside the component or in a .css file with the same name beside it. Components shows both. They work the same: Bascik scopes the CSS, writes it once per page however many instances there are, and moves it into the page <head>, so no <style> tags are left in the body.

If a component contains multiple <style> tags or both a paired .css file and inline <style> tags, Bascik combines all stylesheets before scoping.

Readability & Maintainability: While Bascik supports multiple <style> tags in a single component file, using multiple <style> tags (or mixing an inline <style> tag with a companion .css file) is not recommended for readability and maintainability. Choose one stylesheet pattern per component.

Use paired files for most components so the HTML and CSS stay easy to scan. Inline <style> tags are convenient when keeping a small component in a single file is preferred.

CSS @import

Bascik supports standard CSS @import statements in component CSS (.css files and inline <style> blocks):

  • Local file imports: Local relative paths (e.g. @import "./tokens.css", @import "../shared/theme.css") are resolved and inlined recursively at build time. All selectors, custom properties, and keyframes inside imported stylesheets are automatically scoped to the component instance.
  • Remote URL imports: External URLs (e.g. @import "https://fonts.googleapis.com/...") are preserved and automatically hoisted to the top of the compiled stylesheet per W3C CSS specifications.
  • Conditions & Cascade Layers: Optional conditions such as layer(...), supports(...), and media query lists attached to @import rules are fully preserved and wrapped around the inlined CSS.
  • Missing files: An import whose file does not exist logs a warning and is replaced by a /* @import "…" not found */ comment. The rules after it are still scoped and emitted.
  • Comments and strings: @import text inside a CSS comment or a quoted string is left as written.
  • Dev server: Editing, creating, or deleting an imported stylesheet rebuilds and reloads every page that uses a component importing it, directly or through another import. The stylesheet can live anywhere, including outside the components directory, as long as a dev watcher covers it (directory.pages, directory.components, or pipeline.watchPaths).

CSS Fragment References

Bascik rewrites fragment-only url(#id) values when the ID is declared in the same component. This supports SVG gradients, masks, filters, clip paths, markers, fills, strokes, and other CSS properties that accept a local fragment URL:

html
<svg>
  <defs><linearGradient id="brand-gradient"></linearGradient></defs>
  <rect class="brand-shape"></rect>
</svg>
css
.brand-shape {
  fill: url(#brand-gradient);
}

Quoted fragments such as url('#brand-gradient') and url("#brand-gradient") work the same way. Bascik leaves real URLs such as url(/images/shape.svg), data URLs, remote URLs, and cross-document fragments such as url(sprite.svg#icon) byte-identical. Fragment rewriting runs before later base-path URL processing, so the two transforms do not claim the same value.

IDs are scoped per component instance. Under the default deduplicateCss: true, one shared stylesheet cannot point to two different instance IDs. Bascik therefore emits per-instance CSS only for components containing a resolvable CSS fragment reference. This is the correct option because sharing the referenced ID across instances would emit invalid duplicate IDs. Components without CSS fragment references continue to deduplicate normally. With deduplicateCss: false, the existing per-instance CSS path handles fragments without any additional opt-out.

References to IDs inside a preserved subtree remain literal. Use Preserve Scoping when another tool owns the SVG ID graph.

How Scoped Names Are Formed

Class names are scoped to the component type so every instance of the same component shares identical scoped class names. This is intentional: it allows Bascik to emit a single <style> block per component regardless of how many times it appears on the page.

ID attributes are scoped per component instance so multiple instances on the same page always have distinct DOM identifiers. name attributes ship as written: a name is the key a form field is submitted under and the group a radio or <details> belongs to, a contract with the rest of the page rather than a private identifier. See Component Best Practices, which also covers the scoping.attributes.name opt-in that scopes name per instance.

text
class      →  bascik__<componentName>__<originalName>
id         →  bascik__<componentName>__<instanceId>__<originalName>
name       →  bascik__<componentName>__<instanceId>__<originalName>   (only with scoping.attributes.name: true)
p { }      →  .b_el_<componentName>_p      (class added to every <p> in the template)
#btn { }   →  .b_id_<componentName>_btn    (class added to the element with id="btn")

Element and #id rules become classes because components have no shadow DOM: a bare p { } would style every paragraph on the page, and the scoped id differs per instance, so one rule could not match them all. Their names start with b_el_ and b_id_ so they never equal a class you write (.el__p becomes bascik__<componentName>__el__p). The Scoping System explains the scheme in full.

In production builds (minify.identifiers: true, the default), these verbose names are hashed to short alphanumeric strings such as b2Y4G9eD1K8b for name compression. The HTML, CSS, and JavaScript are all rewritten with the same hashed names so they stay in sync. This is entirely a build-time transformation with no runtime overhead.

Class Selectors in Component Scripts

Because all instances of the same component share the same scoped class names (for CSS deduplication), document.querySelector('.myClass') inside a component script will always return the first matching element on the page, not necessarily the element belonging to the current instance. If you have multiple instances of the same component, each instance's script will target the same (first) element.

The correct pattern is to use an id attribute as your root anchor. id attributes are scoped per-instance, so getElementById always finds exactly the right element:

html
<!-- my-comp.html -->
<div id="root" class="wrapper">
  <button id="btn">Click</button>
</div>

<script>
  const root = document.getElementById("root"); // ✓ unique per instance
  const btn = root.querySelector("button"); // ✓ scoped to this instance
</script>

Avoid this anti-pattern:

html
<div class="wrapper">
  <button class="btn">Click</button>
</div>

<script>
  // ✗ finds the FIRST .wrapper on the page - wrong for instance 2+
  const root = document.querySelector(".wrapper");
</script>

By default, all instances of the same component share identical scoped class names so Bascik can emit a single <style> block per component, regardless of how many times it appears on the page. If you genuinely need class selectors to be unique per instance (for example, to use querySelector safely across multiple instances), set scoping.deduplicateCss to false:

ts
import { defineConfig } from "@bascik/bascik/config";

export default defineConfig({
  scoping: {
    deduplicateCss: false, // each instance gets its own unique class names
  },
});

deduplicateCss Trade-Off Comparison

Setting scoping.deduplicateCss in bascik.config.ts controls whether class names are scoped per component type or per component instance.

Go deeper. To understand how Bascik collects, scopes, and compiles CSS blocks at the parser level, check out the CSS Deduplication internals guide.

Feature / AspectdeduplicateCss: true (Default)deduplicateCss: false
Class Scoping Schemebascik__card__wrapper (shared per component)bascik__card__a1b2c3d4__wrapper (unique per instance)
CSS PayloadSingle <style> block per component type, zero CSS duplicationMultiplied <style> blocks (one block per component instance)
querySelector('.cls') BehaviorTargets the first instance on the pageTargets the matching element inside that specific instance
Instance Isolation ModelUse id + getElementById() for per-instance script isolationClass selectors inherently isolate per instance
Best ForVirtually all production sites and design systemsMigrating legacy or third-party code that relies on class queries

Side-by-Side Code Example

Given two instances of <my-card> on the same page:

html
<!-- Input Page HTML -->
<my-card></my-card>
<my-card></my-card>

Output with deduplicateCss: true (Default, Shared Class Scope):

html
<!-- HTML Output: Shared classes, unique IDs -->
<div class="bascik__my-card__wrapper" id="bascik__my-card__a1b2c3d4__root">
  ...
</div>
<div class="bascik__my-card__wrapper" id="bascik__my-card__e5f6g7h8__root">
  ...
</div>

<!-- CSS Output: Emitted once in <head> -->
<style>
  .bascik__my-card__wrapper {
    padding: 1rem;
  }
</style>

Output with deduplicateCss: false (Per-Instance Class Scope):

html
<!-- HTML Output: Unique classes per instance -->
<div
  class="bascik__my-card__a1b2c3d4__wrapper"
  id="bascik__my-card__a1b2c3d4__root"
>
  ...
</div>
<div
  class="bascik__my-card__e5f6g7h8__wrapper"
  id="bascik__my-card__e5f6g7h8__root"
>
  ...
</div>

<!-- CSS Output: Emitted for every instance -->
<style>
  .bascik__my-card__a1b2c3d4__wrapper {
    padding: 1rem;
  }
  .bascik__my-card__e5f6g7h8__wrapper {
    padding: 1rem;
  }
</style>

Using the id-based pattern with getElementById() is recommended because it gives you per-instance JS isolation while keeping deduplicateCss: true for minimal CSS payload.

Toggling Scoping

Attribute scoping can be controlled in bascik.config.ts. The following is an illustrative reference showing the defaults, so do not add it unless a project needs to disable one of these options:

ts
import { defineConfig } from "@bascik/bascik/config";

export default defineConfig({
  scoping: {
    attributes: {
      class: true, // scope class names
      id: true, // scope id attributes
      name: false, // name ships as written (true scopes it per instance)
    },
  },
});

MDN reference. Scoped CSS changes how selectors are rewritten at build time, but the CSS you write is still normal CSS. Use MDN's CSS reference as the primary source for selectors, at-rules, and properties.

Vendor Prefixing & BYOMinifier Post-Processing (PostCSS, LightningCSS)

Bascik's built-in CSS scoping pipeline is ultra-fast, zero-dependency, and lightweight. It handles selector scoping, custom property isolation, and whitespace minification out of the box.

Thanks to Bascik's BYOMinifier (Bring Your Own Minifier) feature, if your project requires browser vendor prefixing (such as -webkit- prefixes for Safari cross-browser compatibility) or advanced CSS transformations, you can plug custom CSS processors like PostCSS with Autoprefixer or LightningCSS directly into minify.css in bascik.config.ts.

PostCSS with Autoprefixer

ts
// bascik.config.ts
import { defineConfig } from "@bascik/bascik/config";
import autoprefixer from "autoprefixer";
import postcss from "postcss";

export const build = defineConfig({
  minify: {
    css: async (css) => {
      const result = await postcss([autoprefixer]).process(css, {
        from: undefined,
      });
      return result.css;
    },
  },
});

LightningCSS

ts
// bascik.config.ts
import { defineConfig } from "@bascik/bascik/config";
import { transform } from "lightningcss";

export const build = defineConfig({
  minify: {
    css: (css) => {
      const { code } = transform({
        filename: "style.css",
        code: Buffer.from(css),
        minify: true,
        targets: { safari: 15 << 16 },
      });
      return code.toString();
    },
  },
});

When configured, Bascik runs your custom minify.css transformer on all scoped component <style> blocks, inlined global stylesheets, and static .css files during bascik --build.

How Scoping Works

This guide focuses on the CSS behavior you write and observe. The compiler implementation is documented separately.

Under the hood. Read the Scoping System internals for the scoping passes, generated attribute maps, selector transformations, and deduplication model.