Skip to content

Styling

busker.css is optional. It styles the cursor, the ripple, the pointer affordance, the miss hint, and the explore hint — about 80 lines, all scoped to .busker. It does not style your mock.

typescript
import '@logfox/busker/busker.css';

Or copy it into your own stylesheet and edit it. There is nothing clever in there.

You do not add pointer markup to your mock. When busk() starts, busker creates a [data-cursor] element on the root unless you already put one there. The default is a soft dot (not an arrow) so it reads as part of the demo, not the visitor’s OS cursor.

Tune the default with CSS variables on the mock root (or any ancestor):

PropertyDefaultWhat it is
--busker-accent#7c3aedPress colour and click ripple. Match your brand.
--busker-cursor-size0.95remDot diameter.
--busker-cursor-fillrgb(0 0 0 / 0.42)Dot fill at rest.
--busker-cursor-edge#fffRing so the dot stays visible on dark UI.
--busker-cursor-shadowrgb(0 0 0 / 0.3)Dot drop shadow.
--busker-cursor-z-index999Above in-mock UI; below typical site chrome (nav, modals). Override on .busker if you need a different stack.
css
.my-mock {
--busker-accent: #e5484d;
--busker-cursor-size: 1.1rem;
}

Use your own shape (arrow, branded SVG, etc.): add one element with data-cursor inside the mock root before you call busk(). Busker moves that element instead of creating a dot. destroy() removes a busker-created cursor only; it leaves yours in the DOM.

html
<span data-cursor aria-hidden="true">
<svg viewBox="0 0 11 18" width="16" aria-hidden="true">
<path d="M0 0 L0 16 L4 12.5 L6.5 18 L9 17 L6.5 11.5 L11 11 Z"/>
</svg>
</span>

Turn off the default dot styling so your art shows through:

css
.busker [data-cursor] {
background: none;
border: 0;
box-shadow: none;
height: auto;
width: auto;
}

Busker only sets translate (position) and the is-* press/ripple classes on [data-cursor]. Size, colour, and artwork are yours.

Why you cannot rely on :hover during the show

Section titled “Why you cannot rely on :hover during the show”

CSS :hover follows the operating-system pointer, not whatever is drawn on screen. The demo pointer is [data-cursor] with pointer-events: none (see busker.css) so it never steals clicks or hovers from the mock underneath. While the scripted cursor glides over a button, the visitor’s mouse may still be on the hero copy — no :hover on that button, even though the demo looks like it is hovering there.

Dispatching synthetic mouseenter / mousemove from JavaScript does not reliably turn on :hover the way a real pointer move does, and it fights focus, menus, and accessibility. There is no supported way to “just use :hover” for the fake cursor without moving the visitor’s actual mouse.

During the show, busker toggles is-hover on the current step target when the demo cursor overlaps that element (and through dwell and press for clicks). It does not set is-hover on every control the cursor passes over — only the wired target for the active step. After takeover (.is-aside), busker clears is-hover; only real :hover applies.

How to style it (one rule, not two themes)

Section titled “How to style it (one rule, not two themes)”

Extend your existing :hover selectors so demo and visitor look identical:

css
.my-button:is(:hover, .is-hover) {
background: var(--accent-muted);
}

Use the same declaration block you would use for :hover alone. Pair nested pieces too (child text, ::after, compact variants):

css
.my-row:is(:hover, .is-hover) .issue-title__text {
color: var(--accent-deep);
}

Do not add a second block scoped to .busker:not(.is-aside) … .is-hover with different colors or opacities. That duplicates hover styling and is the usual reason demo hover looks “off” compared to poking the mock after takeover — two sources of truth drift apart.

You do not need .is-interactive in the selector for hover paint; busker only adds is-interactive for the pointer cursor and miss hint. Hover pairing belongs on the control’s normal class names.

After a visitor takes over (.is-aside), :hover alone is enough; keep :is(:hover, .is-hover) anyway so one rule covers both phases.

While the cursor is down on a click step, busker adds is-pressing to [data-cursor] and is-pressed to button, [data-busker-press], and [role="button"].is-interactive — the same window as a real :active (200ms). Mark chip-style controls with data-busker-press. Use data-busker-no-press on inline back lines and text links.

Pair pressed styles the same way as hover:

css
.mock-btn {
transition: background 0.16s ease, box-shadow 0.16s ease; /* not transform — snap press */
}
.mock-btn:is(:active, .is-pressed) {
transform: scale(0.96);
box-shadow: inset 0 1px 0 rgb(0 0 0 / 0.12);
}

Chip-style filters and nav items usually only need a darker background, not a scale. Omit transform from the base transition so press/release stay sharp. If the step selector hits a child inside a <button>, busker walks up to the pressable wrapper for is-pressed.

Dropdown fields: put data-busker-press on the chip trigger and data-busker-menu on the panel. Menu row clicks get demo hover on the row only — not is-pressed on the trigger behind them.

Step target is a child of the visible control

Section titled “Step target is a child of the visible control”

click / clickTargets selectors often point at a inner node (for example [data-live-trace="two"] on a row inside a <button.mock-logs__trace-drill>). Busker puts is-hover on that node, not the outer button. If your :hover rule styles a child from the parent (button:hover .row), add a demo branch, e.g. button:has(.row.is-hover) .row, or pair :is(:hover, .is-hover) on the same element that receives is-hover.

When the routine sets exploreHint, busker appends the pill to document.body — position: fixed, viewport coordinates — so mock overflow: hidden never clips it:

html
<span data-explore-hint><span data-explore-hint-pop><span data-explore-hint-face>Click to explore</span></span></span>

The outer span follows the pointer with left / top only (no transforms on that node). [data-explore-hint-pop] scales in and out; [data-explore-hint-face] is a painted frosted pill (gradient, border, shadow — no backdrop-filter, so follow + pop stay smooth).

Because the pill is appended to document.body, busker.css defines explore-hint colours on :root, not on .busker. They track prefers-color-scheme and html[data-theme='light'|'dark'] (Starlight and similar doc themes) automatically. Override on .busker only when a mock needs its own pill (for example a always-light product frame on a dark landing page). Restyle the pill through [data-explore-hint-face]:

css
[data-explore-hint-face] {
background: var(--accent);
color: #fff;
}

--busker-hint-bg and --busker-hint-ink cover the common case. Horizontal gap from the pointer defaults to 1.75rem — override with offsetX on ExploreHintConfig or --busker-hint-offset-x on the root for CSS-only tweaks (the JS default matches the variable). Per-visit duration defaults to 1800ms via dismissAfterMs or durationMs (starts when the hint pops in; moving the mouse does not extend it). The pill sits one step below the cursor (calc(var(--busker-cursor-z-index) - 1)) and never intercepts clicks; any click on the mock hides it until the next hover (takeover calls dismiss() for good). Under prefers-reduced-motion the pop and fade are off and the follow snaps instead of gliding.

Product layout, nav, modals, and transitions live in your stylesheets. Busker never reads your nav or page markup and never hides siblings for you. When a step or a visitor clicks, your handlers update the DOM; busker only moves the cursor and toggles the affordance classes in Markup.

is-ringing lasts 500ms and is-hint lasts 1.5s. Those numbers are in busker.css as animation durations and in the JavaScript as the moment it takes the class off again. Change one, change the other.

busker.css turns off the cursor transition and the hint animation under prefers-reduced-motion: reduce. The loop itself is already off — that is handled in JavaScript, not CSS, so a frozen mock costs nothing.

← When a visitor takes over · Next: API reference