Appearance
@carrot/design-vue-hotspots
A registration and discovery system for interactive hotspots. Provides declarative markup for hotspot regions and an imperative control plane for activating, deactivating, and querying them. No built-in visuals — consumers decide what "active" looks like.
An optional overlay layer provides positioned highlights, info panels with viewport-aware clamping, and click-to-reveal behaviour.
Installation
ts
import { CarrotHotspot, useHotspots, useHotspotsContext } from "@carrot/design-vue-hotspots";
// Optional: overlay components + styles
import { CarrotHotspotOverlay, CarrotHotspotInfoPanel } from "@carrot/design-vue-hotspots";
import "@carrot/design-vue-hotspots/overlay.css";No CSS package is required for the core registration system — it is purely behavioural. The overlay CSS is only needed when using CarrotHotspotOverlay or CarrotHotspotInfoPanel.
Components
<CarrotHotspot>
Wrapper component that registers a hotspot region. Registers on mount, unregisters on unmount.
Usage
vue
<!-- 1. Establish a registry at the page/layout level -->
<script setup lang="ts">
import { useHotspots, CarrotHotspot } from "@carrot/design-vue-hotspots";
const hotspots = useHotspots();
</script>
<!-- 2. Mark hotspot regions -->
<CarrotHotspot id="revenue-chart" :meta="{ group: 'tour', step: 1 }">
<template #default="{ active, data }">
<RevenueChart :class="{ 'pulse': active }" />
<HelpPanel v-if="active" :text="data?.helpText" />
</template>
</CarrotHotspot>Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Required. Unique hotspot identifier. |
meta | Record<string, unknown> | {} | Static metadata (e.g. group, step, label). |
tag | string | "div" | HTML tag for the wrapper element. |
Scoped Slot Props
| Prop | Type | Description |
|---|---|---|
active | boolean | Whether this hotspot is currently active. |
data | Record<string, unknown> | null | Data passed by the consumer on activation. |
entry | HotspotEntry | undefined | Full registry entry (el, rect, meta, active, data). |
Events
| Event | Payload | Description |
|---|---|---|
activated | Record<string, unknown> | null | Fired when this hotspot is activated. |
deactivated | — | Fired when this hotspot is deactivated. |
Data Attributes
| Attribute | Value | Description |
|---|---|---|
data-hotspot | {id} | Always present. The hotspot identifier. |
data-hotspot-active | (presence) | Present only when active. Target with CSS. |
<CarrotHotspotOverlay>
Renders positioned highlight boxes over registered hotspots. Manages selection state (single open at a time) and delegates rendering to <CarrotHotspotOverlayItem> + a scoped slot.
Requires @carrot/design-vue-hotspots/overlay.css.
Usage
vue
<script setup lang="ts">
import { ref } from "vue";
import { useHotspots, CarrotHotspot, CarrotHotspotOverlay, CarrotHotspotInfoPanel } from "@carrot/design-vue-hotspots";
import type { HotspotOverlayDef } from "@carrot/design-vue-hotspots";
const hotspots = useHotspots();
const showOverlay = ref(true);
const defs: HotspotOverlayDef[] = [
{ id: "player", placement: "bottom", meta: { title: "Player", description: "Controls playback." } },
{ id: "queue", placement: "right", meta: { title: "Queue", description: "Upcoming tracks." } },
];
</script>
<template>
<CarrotHotspot id="player">
<template #default>
<PlayerComponent />
</template>
</CarrotHotspot>
<CarrotHotspot id="queue">
<template #default>
<QueueComponent />
</template>
</CarrotHotspot>
<CarrotHotspotOverlay :defs="defs" :active="showOverlay">
<template #default="{ def, selected, toggle, close, placement, anchorEl }">
<CarrotHotspotInfoPanel :open="selected" :placement="placement" :anchor="anchorEl">
<p class="carrot-hotspot-info-title">{{ def.meta?.title }}</p>
<p>{{ def.meta?.description }}</p>
</CarrotHotspotInfoPanel>
</template>
</CarrotHotspotOverlay>
</template>Props
| Prop | Type | Default | Description |
|---|---|---|---|
defs | HotspotOverlayDef[] | — | Required. One definition per hotspot to decorate. |
active | boolean | false | Master on/off. When false, nothing renders. |
color | string | undefined | CSS color for pulse highlights. Falls back to CSS variable chain. |
Scoped Slot Props (HotspotOverlaySlotProps)
| Prop | Type | Description |
|---|---|---|
def | HotspotOverlayDef | The definition for this item. |
selected | boolean | Whether this item's content is currently showing. |
toggle | () => void | Toggle this item's content on/off. |
close | () => void | Close this item's content. |
rect | DOMRect | null | Current bounding rect (viewport-relative, with padding). |
placement | HotspotPlacement | Preferred placement from the def. |
anchorEl | HTMLElement | null | The overlay item's root element — pass to InfoPanel. |
Behaviour
- Only one item can be selected at a time. Clicking another item closes the previous one.
- Clicking outside any overlay item closes the selected item.
- When
activebecomes true, all matching hotspots in the registry are activated. When false, all are deactivated and selection is cleared. - Late-registering hotspots (e.g. from async routes) are auto-activated if
activeis already true.
<CarrotHotspotOverlayItem>
Renders a single positioned highlight box over a hotspot element. Tracks the element's bounding rect via rAF. Used internally by CarrotHotspotOverlay but can be used standalone if you need custom selection logic.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
def | HotspotOverlayDef | — | Required. Overlay definition for this item. |
entry | HotspotEntry | — | Required. Registry entry for this hotspot. |
selected | boolean | — | Whether this item's content is showing. |
color | string | undefined | CSS color override for pulse highlight. |
Events
| Event | Description |
|---|---|
toggle | Emitted when the item is clicked (mode 'info' only). |
close | Emitted to request closing. |
<CarrotHotspotInfoPanel>
A viewport-aware anchored panel for displaying info content next to a hotspot. Supports automatic placement flipping and arrow offset correction when the panel is clamped to viewport edges.
Requires @carrot/design-vue-hotspots/overlay.css.
Usage
vue
<CarrotHotspotInfoPanel
:open="isOpen"
placement="bottom"
:anchor="anchorElement"
:margin="12"
auto-flip
>
<p class="carrot-hotspot-info-title">Progress Bar</p>
<p>Read-only playback progress. Staff can see how long is left.</p>
</CarrotHotspotInfoPanel>Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | false | Whether the panel is visible. |
placement | HotspotPlacement | "bottom" | Preferred placement relative to the anchor. May be overridden by auto-flip. |
anchor | HTMLElement | null | null | The element to anchor to — typically the overlay item's root. |
margin | number | 8 | Minimum distance (px) from viewport edges when clamping. |
autoFlip | boolean | true | When true, flips to the opposite side if the preferred placement doesn't have enough room. |
Positioning Behaviour
The panel positions itself with a three-step process:
- Auto-flip — if
autoFlipis true, checks available space on the preferred side vs the opposite. Flips if the opposite has enough room and the preferred side doesn't. If neither side fits, picks whichever has more room. - Viewport clamping — the panel's cross-axis position (horizontal for top/bottom, vertical for left/right) is clamped so the panel stays within
viewport - marginon all edges. - Arrow tracking — the arrow tick repositions to point back at the anchor's center, clamped so it never escapes the panel's border radius.
CSS Classes
| Class | Description |
|---|---|
.carrot-hotspot-info-panel | Root panel container. |
.carrot-hotspot-info-panel--{placement} | Placement modifier (top/bottom/left/right). |
.carrot-hotspot-info-panel__body | Content area. |
.carrot-hotspot-info-panel__arrow | Arrow tick element. |
.carrot-hotspot-info-title | Title text inside the body. |
Transition
Uses Vue <Transition name="carrot-hotspot-info"> — classes are defined in overlay.css.
Composables
useHotspots()
Creates a hotspot registry, provides it to the subtree via provide(), and returns the HotspotRegistry control plane. Call once per scope.
ts
import { useHotspots } from "@carrot/design-vue-hotspots";
const hotspots = useHotspots();
// Activate with optional data
hotspots.activate("revenue-chart", { helpText: "This shows Q3 projections" });
// Deactivate
hotspots.deactivate("revenue-chart");
hotspots.deactivateAll();
// Query
hotspots.isActive("revenue-chart"); // boolean
hotspots.get("revenue-chart"); // HotspotEntry | undefined
hotspots.activeIds.value; // string[]
hotspots.activeEntries.value; // HotspotEntry[]
hotspots.entries.value; // HotspotEntry[] (all)
// Refresh bounding rects (e.g. after scroll or layout shift)
hotspots.refresh(); // all
hotspots.refresh("revenue-chart"); // singleuseHotspotsContext()
Injects an existing registry from an ancestor. Throws if none found. Use this in child components that need access to the control plane without creating a new scope.
ts
import { useHotspotsContext } from "@carrot/design-vue-hotspots";
const hotspots = useHotspotsContext(); // throws if no ancestor called useHotspots()Types
ts
type HotspotMeta = Record<string, unknown>;
type HotspotActivationData = Record<string, unknown>;
interface HotspotEntry {
readonly id: string;
readonly el: HTMLElement;
rect: DOMRect;
readonly meta: HotspotMeta;
active: boolean;
data: HotspotActivationData | null;
}
interface HotspotRegistry {
register(id: string, el: HTMLElement, meta?: HotspotMeta): void;
unregister(id: string): void;
activate(id: string, data?: HotspotActivationData): void;
deactivate(id: string): void;
deactivateAll(): void;
isActive(id: string): boolean;
get(id: string): HotspotEntry | undefined;
refresh(id?: string): void;
readonly entries: ComputedRef<HotspotEntry[]>;
readonly activeIds: ComputedRef<string[]>;
readonly activeEntries: ComputedRef<HotspotEntry[]>;
}
type HotspotPlacement = "top" | "bottom" | "left" | "right";
interface HotspotOverlayDef {
id: string;
padding?: number; // default 0
placement?: HotspotPlacement; // default 'bottom'
mode?: "info" | "highlight"; // default 'info'
meta?: Record<string, unknown>;
}
interface HotspotOverlaySlotProps {
def: HotspotOverlayDef;
selected: boolean;
toggle: () => void;
close: () => void;
rect: DOMRect | null;
placement: HotspotPlacement;
anchorEl: HTMLElement | null;
}
interface CarrotHotspotInfoPanelProps {
open?: boolean; // default false
placement?: HotspotPlacement; // default 'bottom'
anchor?: HTMLElement | null; // default null
margin?: number; // default 8
autoFlip?: boolean; // default true
}Example: Guided Tour
vue
<script setup lang="ts">
import { useHotspots, CarrotHotspot } from "@carrot/design-vue-hotspots";
const hotspots = useHotspots();
const tourSteps = [
{ id: "nav-bar", helpText: "Use the nav bar to switch sections." },
{ id: "revenue-chart", helpText: "This shows your revenue trends." },
{ id: "settings-btn", helpText: "Configure your dashboard here." },
];
let currentStep = 0;
function startTour() {
const step = tourSteps[currentStep];
hotspots.activate(step.id, { helpText: step.helpText });
}
function nextStep() {
hotspots.deactivateAll();
currentStep++;
if (currentStep < tourSteps.length) {
const step = tourSteps[currentStep];
hotspots.activate(step.id, { helpText: step.helpText });
}
}
</script>Dependencies
vue(peer)@carrot/core(runtime)
No CSS dependencies for the core registration system — it is purely behavioural. Import @carrot/design-vue-hotspots/overlay.css when using overlay components.
Usage Guide
Setup
ts
import { CarrotHotspot, useHotspots, useHotspotsContext } from "@carrot/design-vue-hotspots";
// No CSS import needed — purely behavioural
// For overlay components:
import { CarrotHotspotOverlay, CarrotHotspotInfoPanel } from "@carrot/design-vue-hotspots";
import "@carrot/design-vue-hotspots/overlay.css";CarrotHotspot + useHotspots
Guided tour
vue
<script setup lang="ts">
import { CarrotHotspot, useHotspots } from "@carrot/design-vue-hotspots";
import { ref } from "vue";
// Call useHotspots once at the page/layout level to create the registry
const hotspots = useHotspots();
const tourSteps = [
{ id: "search-bar", helpText: "Use the search bar to find tracks by BPM, key, or genre." },
{ id: "filter-panel", helpText: "Narrow results with the filters on the left." },
{ id: "results-grid", helpText: "Click any track to preview it." },
];
const currentStep = ref(-1);
const isTourActive = computed(() => currentStep.value >= 0);
function startTour() {
currentStep.value = 0;
activateStep();
}
function activateStep() {
hotspots.deactivateAll();
const step = tourSteps[currentStep.value];
if (step) hotspots.activate(step.id, { helpText: step.helpText });
}
function nextStep() {
currentStep.value++;
if (currentStep.value < tourSteps.length) {
activateStep();
} else {
hotspots.deactivateAll();
currentStep.value = -1;
}
}
</script>
<template>
<div>
<CarrotButton @click="startTour">Start Tour</CarrotButton>
<CarrotHotspot id="search-bar">
<template #default="{ active, data }">
<SearchBar :class="{ highlighted: active }" />
<TourTooltip v-if="active" :text="data?.helpText" @next="nextStep" />
</template>
</CarrotHotspot>
<CarrotHotspot id="filter-panel">
<template #default="{ active, data }">
<FilterPanel :class="{ highlighted: active }" />
<TourTooltip v-if="active" :text="data?.helpText" @next="nextStep" />
</template>
</CarrotHotspot>
<CarrotHotspot id="results-grid">
<template #default="{ active, data }">
<ResultsGrid :class="{ highlighted: active }" />
<TourTooltip v-if="active" :text="data?.helpText" @next="nextStep" />
</template>
</CarrotHotspot>
</div>
</template>CSS-driven highlight via data attribute
vue
<style>
/* Style active hotspots with the data attribute — no JS class needed */
[data-hotspot-active] {
outline: 2px solid var(--color-accent);
outline-offset: 4px;
border-radius: var(--radius-md);
}
</style>
<template>
<CarrotHotspot id="my-feature">
<template #default>
<MyFeatureComponent />
</template>
</CarrotHotspot>
</template>useHotspotsContext
Accessing the registry from a child component
vue
<script setup lang="ts">
import { useHotspotsContext } from "@carrot/design-vue-hotspots";
// Injects the registry established by an ancestor's useHotspots() call
const hotspots = useHotspotsContext();
function highlightRevenue() {
hotspots.activate("revenue-chart", { label: "Key metric" });
}
</script>Overlay + Info Panels
Basic overlay with info panels
vue
<script setup lang="ts">
import { ref } from "vue";
import {
useHotspots,
CarrotHotspot,
CarrotHotspotOverlay,
CarrotHotspotInfoPanel,
} from "@carrot/design-vue-hotspots";
import type { HotspotOverlayDef } from "@carrot/design-vue-hotspots";
const hotspots = useHotspots();
const showInfo = ref(false);
const defs: HotspotOverlayDef[] = [
{
id: "player",
placement: "bottom",
padding: 4,
meta: { title: "Now Playing", description: "Current track and playback controls." },
},
{
id: "queue",
placement: "right",
meta: { title: "Queue", description: "Drag to reorder upcoming tracks." },
},
{
id: "visualiser",
placement: "top",
mode: "highlight", // pulse only, no click-to-reveal
},
];
</script>
<template>
<button @click="showInfo = !showInfo">Toggle Info</button>
<CarrotHotspot id="player">
<template #default><PlayerComponent /></template>
</CarrotHotspot>
<CarrotHotspot id="queue">
<template #default><QueueComponent /></template>
</CarrotHotspot>
<CarrotHotspot id="visualiser">
<template #default><VisualiserComponent /></template>
</CarrotHotspot>
<CarrotHotspotOverlay :defs="defs" :active="showInfo">
<template #default="{ def, selected, placement, anchorEl }">
<CarrotHotspotInfoPanel :open="selected" :placement="placement" :anchor="anchorEl">
<p class="carrot-hotspot-info-title">{{ def.meta?.title }}</p>
<p>{{ def.meta?.description }}</p>
</CarrotHotspotInfoPanel>
</template>
</CarrotHotspotOverlay>
</template>Info panel with custom margin and no auto-flip
vue
<!-- Wider margin from viewport edges, no automatic placement flip -->
<CarrotHotspotInfoPanel
:open="selected"
placement="left"
:anchor="anchorEl"
:margin="16"
:auto-flip="false"
>
<p class="carrot-hotspot-info-title">Fixed Left</p>
<p>This panel always appears on the left, even if space is tight.</p>
</CarrotHotspotInfoPanel>Edge-anchored hotspot (viewport clamping + arrow tracking)
When a hotspot is near a viewport edge, the info panel clamps itself within bounds and the arrow tick shifts to continue pointing at the anchor:
vue
<!-- Hotspot near the right edge of the screen -->
<CarrotHotspot id="edge-button" style="position: absolute; right: 20px;">
<template #default>
<button>Settings</button>
</template>
</CarrotHotspot>
<!-- Panel will clamp left to stay in viewport; arrow shifts right to track the button -->No special configuration needed — clamping and arrow correction are always active.
Common Patterns
React to activation events
vue
<template>
<CarrotHotspot
id="revenue-chart"
:meta="{ category: 'finance' }"
@activated="onActivated"
@deactivated="onDeactivated"
>
<template #default="{ active }">
<RevenueChart :pulse="active" />
</template>
</CarrotHotspot>
</template>Query all active hotspots
vue
<script setup lang="ts">
import { useHotspots } from "@carrot/design-vue-hotspots";
const hotspots = useHotspots();
// Reactive list of all currently active IDs
const activeIds = hotspots.activeIds;
// e.g. activeIds.value → ["revenue-chart", "nav-bar"]
</script>