# Styling & custom CSS

> Every supported way to restyle StepsKit today: dashboard theme editors, --sk-* CSS variables, data-sk-* targeting attributes, best-effort sk- class names, and a scoped Custom CSS escape hatch.

*Source: https://stepskit.com/docs/styling*

This page covers everything you can restyle today, in order of how much
control it gives you: the dashboard theme editors, `--sk-*` CSS variables,
`data-sk-*` targeting attributes, best-effort `sk-` class names, and — as a
last resort — project Custom CSS. Most teams never get past the first
section.

## Theme editors first

Colors, typography, alignment, button radius, overlay, and progress display
are all dashboard settings — no CSS needed. The tour and tooltip editors also
have a **Shape & spacing** section (popover padding, corner radius, border
width/color, shadow, max width) plus a font-family picker and a close-icon
color override, and tours get an explicit footer-button padding control too.
Each surface has its own project-wide default, with per-item overrides on top:

- **Tours** — `/app/<project>/theme/tours`, with per-tour overrides in the
  tour builder.
- **Tooltips** — `/app/<project>/theme/tooltips`, with per-tooltip overrides.
- **Announcements** — `/app/<project>/theme/announcements`.
- **Surveys** — `/app/<project>/theme/surveys`.

Reach for the rest of this page only for what the editors don't expose yet —
mainly the announcement and survey cards, whose padding and font family are
CSS-variable-only today, and anything you'd rather set from your own
stylesheet than the dashboard.

## CSS variables (supported)

Set any of these as custom properties on your own page — typically on
`:root` — and StepsKit picks them up. Every variable falls back to its
current default, so setting nothing changes nothing:

| Variable | Applies to | Default | Theme setting | Notes |
| --- | --- | --- | --- | --- |
| `--sk-popover-padding` | Tour & tooltip popovers | `20px` | Shape & spacing → Padding | Accepts any padding value, including the two- and four-value forms. |
| `--sk-popover-radius` | Tour & tooltip popovers | `12px` | Shape & spacing → Corner radius | |
| `--sk-popover-border-width` | Tour & tooltip popovers | `0` (no border) | Shape & spacing → Border width | |
| `--sk-popover-border-color` | Tour & tooltip popovers | `currentColor` | Shape & spacing → Border color | Only visible once the border width is set. |
| `--sk-popover-shadow` | Tour & tooltip popovers | `0 1px 10px #0006` | Shape & spacing → Shadow | Takes a full `box-shadow` value; use `none` to remove it. |
| `--sk-popover-max-width` | Tour & tooltip popovers | `300px` | Shape & spacing → Max width | The 250px minimum width shrinks with it — see the browser note below. |
| `--sk-font-family` | Every surface | the built-in Inter stack | Typography → Font | Banners and surveys let you override it on their own with `--sk-banner-font-family` / `--sk-survey-font-family`, which win when set. The tooltip indicator (`.sk-tooltip-icon`) reads it too, falling back to this same stack minus the two emoji families when it's unset. |
| `--sk-button-padding-x` | Tour popovers only | `16px` | Buttons → Horizontal padding | Horizontal padding on the tour popover's Back/Next buttons. A tooltip's CTA button sets its own padding inline from its size setting, which outranks this variable even when the tooltip's footer itself is visible. |
| `--sk-button-padding-y` | Tour popovers only | `8px` | Buttons → Vertical padding | Vertical padding on the tour popover's Back/Next buttons. Same override as `--sk-button-padding-x` — a tooltip's CTA sets its own padding inline. |
| `--sk-close-button-color` | Tour popovers only | `#6b7280` | Popover → Close icon | Also applies to the close button's hover/focus state, so setting it removes the default hover darkening. A tooltip popover always paints its own close color from its theme, so this variable has no effect there. |
| `--sk-banner-padding` | Announcement banner | `10px 16px` | — | Overrides both the desktop default and the narrow-screen default (`10px 12px`). No dashboard control yet. |
| `--sk-banner-font-family` | Announcement banner | the system-font stack | — | Wins over `--sk-font-family` when both are set. No dashboard control yet. |
| `--sk-survey-padding` | Survey card | `16px 18px` | — | No dashboard control yet. |
| `--sk-survey-font-family` | Survey card | the system-font stack | — | Wins over `--sk-font-family` when both are set. No dashboard control yet. |
| `--sk-survey-radius` | Survey card | `12px` | — | No dashboard control yet. |

```css
:root {
  --sk-popover-padding: 12px;
  --sk-popover-radius: 8px;
  --sk-popover-border-width: 1px;
  --sk-popover-border-color: #e5e7eb;
  --sk-popover-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  --sk-font-family: "Inter", system-ui, sans-serif;
  --sk-button-padding-x: 12px;
  --sk-button-padding-y: 6px;
}
```

A few things worth knowing before you set these:

- **Use `px` units** for lengths. Invalid values fall back to the default
  above rather than breaking the surface.
- **`:root { --sk-font-family: inherit }` does not do what it looks like it
  does.** `inherit` is a CSS-wide keyword that applies to the custom property
  itself, not to whatever later consumes it through `var()` — so the popover
  ends up inheriting nothing useful. To make StepsKit use your page's own
  font, write `.sk-popover { font-family: inherit !important }` in your
  stylesheet instead.
- **The 250px popover minimum width needs a modern browser.** It's computed
  with CSS `min()`, which needs Chrome 79+, Safari 11.1+, or Firefox 75+.
  Everything else about these variables works everywhere; on an older
  browser you simply get no minimum width instead of a 250px floor.

These names are the supported customer styling surface. We won't rename them
without a deprecation period.

## How the cascade works

The general rule covers every variable in the table above: your `:root`
variable applies for as long as the matching dashboard setting is empty, and a
value you set in the theme editor wins once you set it. That holds for every
theme property, not only the ones with a `--sk-*` hook — a dashboard value is
applied after your stylesheet loads, most of them as an `!important` property
on the popover. Overlay color and opacity are the one exception: the embed
sets those as inline attributes on the overlay SVG rather than through an
`!important` rule, but they still take effect after your stylesheet either
way, and there's no `--sk-overlay-*` variable to set them from CSS at all. To
override a dashboard value from your own stylesheet, add `!important` at one
specificity level higher than the dashboard's own rule (a single class), for
example:

```css
body .sk-popover {
  background-color: #fff !important;
}
```

That "one level higher" rule is for your own stylesheet. Custom CSS (below)
sits later in the cascade than your stylesheet does, so a rule there only
needs to *match* the dashboard rule's specificity, not exceed it — see
[Where Custom CSS sits in the cascade](#where-custom-css-sits-in-the-cascade)
for the full ladder, including the exact selector for every theme key.

Five variables have no matching dashboard setting yet — `--sk-banner-padding`,
`--sk-banner-font-family`, `--sk-survey-padding`, `--sk-survey-font-family`,
and `--sk-survey-radius` — so those always apply uncontested; nothing on the
dashboard side can currently override them.

Per-step overrides made in the tour builder are applied as inline styles and
can't be overridden from your stylesheet at all — inline beats every
selector, `!important` or not.

> **Warning**
>
> Never set `position` or `z-index` on `.sk-popover` or `.sk-overlay`. Both
> are pinned deliberately — the popover to `position: fixed` and a
> maximum `z-index`, the overlay one step below it — so tours render
> correctly on top of pages with their own transformed or stacked layouts.
> Overriding either can break every tour on the page.

## Targeting one tour, step, tooltip, banner, or survey

Every rendered surface carries a `data-sk-*` attribute naming what it is, so
you can restyle one item instead of every popover on the page:

| Attribute | Where | Value |
| --- | --- | --- |
| `data-sk-tour` | Tour popover | The tour's slug. |
| `data-sk-tour-id` | Tour popover | The tour's id — stable across renames (a slug is re-derived from the tour's name on every rename). |
| `data-sk-step` | Tour popover | The step's 1-based position in the whole tour. This is not the same number as the popover's own progress text ("1 of 2"), which counts only the steps on the current page. |
| `data-sk-step-type` | Tour popover | The step type (`callout` or `modal`). |
| `data-sk-tooltip` | Tooltip indicator and tooltip popover | The tooltip's id. |
| `data-sk-announcement` | Announcement banner | The announcement's id. |
| `data-sk-survey` | Survey card | The survey's id. |

Combine an attribute with a CSS variable to restyle one tour without
touching any other:

```css
.sk-popover[data-sk-tour="welcome-tour"] {
  --sk-popover-padding: 8px;
}
```

Or target a class directly, for a per-step rule a variable can't express:

```css
.sk-popover[data-sk-step="1"] .sk-popover-title {
  text-transform: uppercase;
}
```

## Classes (best effort)

The class names below are real and tested, but the popover's internal markup
can change as the product evolves — treat them as a looser contract than the
CSS variables and attributes above.

**Tour popover**

`sk-popover`, `sk-popover-arrow`, `sk-popover-close-btn`,
`sk-popover-title`, `sk-popover-description`, `sk-popover-image`,
`sk-popover-footer`, `sk-popover-progress-text`, `sk-popover-progress-dots`,
`sk-popover-progress-dot`, `sk-popover-navigation-btns`,
`sk-popover-prev-btn`, `sk-popover-next-btn`, `sk-overlay`

**Tooltip**

`sk-tooltip-popover` (the popover), `sk-tooltip-icon` (the indicator),
`sk-tooltip-cta-btn` (its call-to-action button).

Modifiers (may change): one `sk-tooltip-icon-*` class per indicator style —
`sk-tooltip-icon-info`, `sk-tooltip-icon-question`, `sk-tooltip-icon-dot`,
`sk-tooltip-icon-badge`, `sk-tooltip-icon-pulse`.

**Announcement banner**

`sk-banner`, `sk-banner-icon`, `sk-banner-content`, `sk-banner-title`,
`sk-banner-body`, `sk-banner-cta`, `sk-banner-dismiss`

**Survey card**

`sk-survey`, `sk-survey-question`, `sk-survey-scores`, `sk-survey-score`,
`sk-survey-textarea`, `sk-survey-actions`, `sk-survey-btn`,
`sk-survey-dismiss`, `sk-survey-thanks`

**Host page markers**

These aren't StepsKit's own elements. They're classes StepsKit adds to
**your** page while a tour runs, so your own stylesheet — or your Custom CSS,
below — can react to one being active:

| Class | Where | Meaning |
| --- | --- | --- |
| `sk-active` | Your `<body>` | A tour is currently running. |
| `sk-fade` | Your `<body>` | Set alongside `sk-active` when step transitions are animated (the default). |
| `sk-simple` | Your `<body>` | Set alongside `sk-active` instead of `sk-fade` when animations are turned off. |
| `sk-active-element` | The element the current step is highlighting | This is YOUR element — the one the tour is pointing at, not something StepsKit rendered. |
| `sk-no-interaction` | The highlighted element | Set alongside `sk-active-element` only on a step that disables interaction with its target. |

All five clear automatically when the tour ends or moves to its next target —
there's nothing to clean up from your side.

Documented classes are covered by tests, and we announce renames. Nesting
and modifier classes not listed here may change without notice.

## Custom CSS (last resort)

Once the theme editor, the `--sk-*` variables, and the classes and
attributes above still don't cover what you need, **Theme → Custom CSS**
(`/app/<project>/theme/custom-css`) is where you write an actual stylesheet.
It's one project-wide sheet, layered under the theme and applied to every
tour, tooltip, announcement banner, and survey — reach for it only for what
the tiers above can't express, since its rules aren't a versioned contract
the way the variables are.

Save it and every visitor picks it up within about 2 minutes — long enough
for any cached copy of the tours payload to expire. No deploy, no rebuild.

You don't have to write it by hand: the editor's **Ask your AI agent** card
copies a prompt with the rules below and your current sheet, and an agent
connected to the [StepsKit MCP](/docs/ai-agents.md) writes and saves it directly
(`update_project_custom_css`).

### The rules

Every selector has to anchor to something StepsKit actually renders or
marks:

- **A `.sk-` class**, anywhere in the selector, outside `:not()` and
  `:has()` — `.sk-popover`, `.sk-banner-title`,
  `[data-sk-tour="welcome"] .sk-popover-title`, and
  `.sk-popover:has(.sk-popover-image)` all anchor; `:not(.sk-popover)` and
  `div:has(.sk-popover)` don't, because the element they actually match isn't
  ours.
- **The host-page markers** from the table above count as anchors too —
  `sk-active`, `sk-active-element`, and the rest.
- **A bare `:root`, `html`, or `body` rule** — but only when every
  declaration in it sets a `--sk-*` custom property. This is what lets a
  no-code install (Tag Manager, a WordPress plugin) set the documented
  variables without writing a rule against any of StepsKit's own classes.

> **Note**
>
> Anchoring is a data boundary, not a promise that Custom CSS can only touch
> StepsKit's own elements. A selector anchored by a `.sk-` class can still
> reach YOUR page: the host markers above (`sk-active` on your `<body>`,
> `sk-active-element` on the element a step is highlighting) and sibling
> combinators like `.sk-popover ~ div` restyle your own elements for as long
> as StepsKit's UI is on screen — and that's by design, since it's what lets
> your stylesheet react to a tour running at all.
>
> The boundary is two rules working together: every selector has to anchor to
> a `.sk-` class, and any attribute it tests outside `data-sk-*` has to come
> from the frozen state list below, for presence or an exact match only. No
> substring, prefix, or suffix matching on a host attribute is what removes
> the read-a-value-one-character-at-a-time channel, and `attr()` and
> `@font-face` are refused outright for the same reason.
>
> What it does **not** close: `https://` URLs are allowed, to any host — a
> stylesheet that can't load your own CDN wouldn't be much use. So an
> exact-match state attribute (`[aria-expanded="true"]`, `[disabled]`) or a
> pseudo-class like `:checked`, used under `body.sk-active`, can still tell
> an outside server one bit of low-entropy page state per rule. The person
> who can write that rule is someone on your own team with dashboard access
> to the project — who could read the same state from your app directly.

- **Attributes** — `[data-sk-*]` selectors accept any operator. Everything
  else is limited to a short, frozen list — `disabled`, `hidden`, `dir`,
  `role`, `type`, `lang`, and `aria-*` — and only for presence (`[role]`) or
  an exact match (`[role="button"]`); substring operators (`^=`, `*=`, …) are
  refused on all of them.
- **URLs** — a `url()`, `src()`, `image()`, or `image-set()` argument must
  be `https://…`, a root-relative `/path` on your own origin, a
  `#fragment`, or a `data:image/…` URI. Plain `http://`, protocol-relative
  `//host`, and `data:text/html` are all refused.
- **At-rules** — `@media`, `@supports`, `@container`, and `@keyframes` are
  allowed. `@font-face` is not: the fonts your own stylesheet already loads
  apply inside every StepsKit surface, shadow-rendered banners and surveys
  included, so point `--sk-font-family` at a family you've already loaded
  instead of trying to declare one here.
- **Size** — 20 KB (20,480 bytes, measured in UTF-8), enforced on save.
- **Errors** — a rejected stylesheet is never rewritten. You get back a
  line and column for every problem, and what's stored is always
  byte-for-byte what you typed.

### Where Custom CSS sits in the cascade

From weakest to strongest, for one property on one element:

1. **The built-in stylesheet** — a `--sk-*` variable, or its fallback.
2. **A plain rule in your Custom CSS**, no `!important` — beats the
   built-in stylesheet outright. This is the rung for properties the theme
   never paints: anything without a theme key, plus the ten nullable keys
   (`popoverPadding`, `popoverRadius`, `popoverBorderWidth`,
   `popoverBorderColor`, `popoverShadow`, `popoverMaxWidth`, `fontFamily`,
   `buttonPaddingX`, `buttonPaddingY`, `closeButtonColor`) while they are
   unset.
3. **A dashboard theme value** — emitted with `!important`, so it beats a
   plain Custom CSS rule. Every other theme key — the colors (`bgColor`,
   `textColor`, `buttonColor`, `buttonTextColor`, `prevButtonColor`,
   `prevButtonTextColor`, `progressDotColor`), font sizes, alignments and
   `buttonRadius` — is painted this way **whether or not you ever set it**,
   with its default when you didn't. So a plain `.sk-popover { background:
   … }` never wins on a tour popover; for those keys go straight to rung 4.
   Only the ten nullable keys above wait for you to set them.
4. **Your Custom CSS with `!important`, at matching specificity** — wins
   the tie, because Custom CSS is always the last stylesheet applied. Unlike
   your own page's stylesheet (see [How the cascade
   works](#how-the-cascade-works) above), you don't need to go a
   specificity level higher here — matching the theme rule's own selector
   depth is enough.
5. **A per-step override** set in the tour builder — inline styles beat
   every stylesheet, `!important` or not.

Every dashboard theme key is emitted on either the popover's root class or
one of its descendants. Match that same depth in your Custom CSS:

| Theme keys | Custom CSS selector |
| --- | --- |
| `bgColor`, `textColor`, `popoverPadding`, `popoverRadius`, `popoverBorderWidth`, `popoverBorderColor`, `popoverShadow`, `fontFamily` | `.sk-popover { … }` |
| `popoverMaxWidth` | `.sk-popover { max-width: …; min-width: …; }` — set both, since the theme emits a matching `min-width` alongside `max-width`; a lone `max-width` override still leaves the 250px floor in place. Simpler: set `--sk-popover-max-width` instead, which drives both by itself. |
| `titleFontSize`, `titleAlign`, and the title's text color | `.sk-popover .sk-popover-title { … }` |
| `bodyFontSize`, `bodyAlign`, and the description's text color | `.sk-popover .sk-popover-description { … }` |
| `buttonColor`, `buttonTextColor`, `prevButtonColor`, `prevButtonTextColor`, `buttonRadius`, `buttonFontSize`, `buttonPaddingX`, `buttonPaddingY` | `.sk-popover .sk-popover-next-btn { … }` and/or `.sk-popover .sk-popover-prev-btn { … }` |
| `closeButtonColor` (tour popovers) | `.sk-popover .sk-popover-close-btn { … }` |
| `buttonsAlign` | `.sk-popover .sk-popover-footer { … }` and `.sk-popover .sk-popover-navigation-btns { … }` |
| `progressDotColor` | `.sk-popover .sk-popover-progress-dot { … }` |
| Tooltip theme keys (same shape, painted on the tooltip popover) | `.sk-tooltip-popover { … }` for root-level keys, `.sk-tooltip-popover .sk-popover-title { … }` for title keys, and so on |

The tooltip's close-icon color is a special case: it's **always** painted
by the theme, even when nothing is set (it falls back to the tooltip's own
text color). To override it, you always need
`.sk-tooltip-popover .sk-popover-close-btn { color: … !important }` —
there's no "unthemed" state to beat instead.

Banners and surveys have no `!important` theme layer at all — a plain
`.sk-banner { … }` or `.sk-survey { … }` rule already wins against their
built-in styles, no `!important` needed.

> **Warning**
>
> Never set `position` or `z-index` on `.sk-popover` or `.sk-overlay` from
> Custom CSS either — see the note above. Both are pinned deliberately so
> tours render correctly on top of pages with their own transformed or
> stacked layouts.

### Browser support

Custom CSS relies on constructable stylesheets, so it needs a reasonably
current browser: Chrome 73+, Edge 79+, Firefox 101+, or Safari 16.4+. On an
older browser, Custom CSS is skipped entirely — that visitor still gets your
theme and the `--sk-*` variables, neither of which has a version floor, just
not your Custom CSS rules.

## Banners and surveys render in shadow roots

Announcement banners and survey cards mount inside a **closed shadow root**,
so a class-based rule in your own page's stylesheet can't reach `.sk-banner`
or `.sk-survey` at all. CSS custom properties are the exception — they
inherit through the shadow boundary regardless of how the root resets
everything else — which is exactly why `--sk-font-family`, `--sk-banner-*`,
and `--sk-survey-*` work on these surfaces from your stylesheet and class
selectors don't.

**Custom CSS is different.** It's adopted directly into every banner and
survey shadow root, not read from your page's stylesheet, so a `.sk-banner`
or `.sk-survey` rule written in Theme → Custom CSS does reach them — the one
selector-based tier (unlike the classes above) that works identically across
all four surfaces. Host-page rules still can't get in; only what you write
in the Custom CSS panel does.

## Dashboard preview

The dashboard's own preview can't see variables set on your site — it
renders in the dashboard's origin, not yours. Your actual visitors, on your
domain, do see them. If a preview and a live tour ever look different, this
is usually why.

## Content Security Policy

None of the styling tiers on this page add a CSP requirement of their own —
Custom CSS included. It's delivered through the CSSOM `adoptedStyleSheets`
API rather than a `<style>` tag, so it sits outside `style-src` entirely; a
stricter policy that omits `'unsafe-inline'` from `style-src` doesn't block
it. If you haven't set up your policy yet, see [Content Security
Policy](/docs/install.md#content-security-policy) for what StepsKit needs
overall — the theme engine's own injected `<style>` tags are what actually
need `style-src 'unsafe-inline'`, and that requirement is unchanged.
