Scoped CSS for Shopify Sections: How We Avoid Theme Conflicts
Global CSS leaks break parent themes. We document the scoping patterns — section.id wrappers, stylesheet tags, BEM, and specificity discipline — we enforce on every client section.
Share with Shopify developers — useful guides spread faster in theme dev communities.
A hero section shipped on Tuesday. By Thursday the collection page inherited its h2 font-size because CSS targeted bare element selectors instead of section.id scope. That regression — and the header overlap bugs that follow — is why scoping rules are non-negotiable on every K2 Devworks export.
The section looked perfect in isolation. Then marketing added it below the header and Dawn's navigation links shrank. Classic unscoped CSS — a hero section set h2 { font-size: 3rem } without tying rules to section instance. On client stores we scope every custom section under #shopify-section-{{ section.id }} or equivalent, use BEM-style class naming under that root, and ban bare element selectors unless explicitly justified. Parent themes — Dawn, Prestige, custom — ship global base styles merchants rely on. Custom sections are guests; they should not rearrange the host's furniture. This guide is our agency CSS contract, paired with Section Workflow phase five and converter output cleanup.
The section root: why section.id is non-negotiable
Shopify wraps each section instance in an element with id shopify-section-{id}. Mirroring that id on an inner wrapper or prefixing all CSS selectors with #shopify-section-{{ section.id }} guarantees rules apply once per instance. Duplicate the same section on a landing page — two heroes — and each keeps independent spacing without class name collisions. Dynamic id means hardcoding #shopify-section-123 in CSS fails after editor reorder regenerates ids — always use Liquid in style blocks or assign a CSS custom property at root. How Shopify Sections Work shows DOM structure in the editor.
{{ section.settings.heading }}
{% stylesheet %}
#shopify-section-{{ section.id }} .hero-split__heading {
font-size: clamp(2rem, 4vw, 3.5rem);
}
{% endstylesheet %}
BEM under the scope root — not global BEM
Block__element--modifier naming reduces specificity wars. hero-split__cta--primary is searchable and grep-friendly. We avoid deep nesting in Sass/Less — Shopify sections rarely need preprocessors. Max three levels of descendant selectors under section root. Do not write #shopify-section-{{ section.id }} .hero-split .inner .content h2 — flat BEM classes on elements instead. Modifier classes reflect schema settings: hero-split--align-left when text_alignment is left.
Ban list: selectors that cause production incidents
- Bare h1, h2, p, a, img — affects entire theme
- .rte without parent scope — collides with product description typography
- .button, .btn — fights Dawn button components
- .container — many themes define global width utilities
- !important except documented fights with legacy app CSS
- ID selectors unrelated to section.id — #main, #content
Mapping Figma CSS to scoped section CSS
Figma export gives global class names — .Frame-427, .Title — meaningless and collision-prone. During converter cleanup we rename to BEM and move rules under section root. Fifteen-minute pass per section beats hours debugging header regressions. Keep Figma px values but convert to rem using theme base — Dawn often uses 1.5rem body; match line-height to parent theme variables when exposed as CSS custom properties in settings_data.
Using theme CSS variables instead of hardcoded hex
Dawn exposes --color-foreground, --color-background, --font-heading-family via color schemes. Custom sections should read rgb(var(--color-foreground)) under scoped rules when color_scheme setting applies. Hardcoded #1a1a1a breaks when merchant switches scheme to dark. If brand guidelines require fixed hero colors, document intentional break in section info setting. Dawn Section Tutorial shows scheme wiring patterns we clone.
#shopify-section-{{ section.id }} .promo-band {
background: rgb(var(--color-background));
color: rgb(var(--color-foreground));
padding: var(--spacing-section, 4rem) 0;
}
Responsive rules scoped per section
Media queries inside scoped CSS affect only matching selectors under section root — safe. Putting unscoped @media (max-width: 749px) { .grid { ... } } at bottom of section file leaks globally. Nest media queries inside #shopify-section-{{ section.id }} block or prefix every selector inside query. Mobile-first versus desktop-first should match parent theme convention for predictable breakpoints — Dawn uses 750px and 990px commonly; we align custom sections to those cuts unless design system documents others.
Multiple instances and modifier classes
Merchants duplicate heroes for seasonal campaigns. Scoped id means CSS duplicates safely. JavaScript must also scope — querySelector within section root element, not document.querySelector('.hero-slider'). Use section.id in data attributes: data-section-id="{{ section.id }}". Carousels that initialize globally break when two instances mount. Performance Tips note JS bundle size — colocate minimal JS with section.
Conflicts with app blocks and snippets
Apps inject CSS with unpredictable specificity. Your section should not try to style inside @app blocks — layout wrapper only. Snippets shared across sections should accept class parameters rather than bringing own global CSS. reviews snippet should not ship .star { color: gold } without scope — pass .product-card__stars from parent.
Typography and .rte islands
Richtext from merchants includes p, ul, strong tags. Scope .rte under section: #shopify-section-{{ section.id }} .rte p { margin-bottom: 1em }. Do not redefine .rte globally. Link styles inside richtext inherit theme link color unless brand requires override — then use .hero-split__body.rte a under scope only.
Grid and flex patterns without fighting theme layout
Avoid setting display: grid on unscoped .page-width children. Use section-inner wrapper you control. Many themes use .page-width for horizontal padding — compose, do not override width on .page-width itself. When Figma shows full-bleed background with constrained content, split background on outer scoped div and max-width on inner.
#shopify-section-{{ section.id }} .split-banner {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
@media screen and (min-width: 990px) {
#shopify-section-{{ section.id }} .split-banner {
grid-template-columns: 1fr 1fr;
}
}
Animations and prefers-reduced-motion
Scoped keyframe names should be unique — hero-split-fade-{{ section.id | replace: '-', '_' }} or generic hero-split-fade with namespace prefix in animation name. Respect prefers-reduced-motion: reduce — disable autoplay sliders. Global @keyframes without prefix can collide if two sections define fadeIn.
CSS load order and {% stylesheet %} tags
OS 2.0 compiles section stylesheets into scoped bundles. Inline