Skip to content

@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

PropTypeDefaultDescription
idstringRequired. Unique hotspot identifier.
metaRecord<string, unknown>{}Static metadata (e.g. group, step, label).
tagstring"div"HTML tag for the wrapper element.

Scoped Slot Props

PropTypeDescription
activebooleanWhether this hotspot is currently active.
dataRecord<string, unknown> | nullData passed by the consumer on activation.
entryHotspotEntry | undefinedFull registry entry (el, rect, meta, active, data).

Events

EventPayloadDescription
activatedRecord<string, unknown> | nullFired when this hotspot is activated.
deactivatedFired when this hotspot is deactivated.

Data Attributes

AttributeValueDescription
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

PropTypeDefaultDescription
defsHotspotOverlayDef[]Required. One definition per hotspot to decorate.
activebooleanfalseMaster on/off. When false, nothing renders.
colorstringundefinedCSS color for pulse highlights. Falls back to CSS variable chain.

Scoped Slot Props (HotspotOverlaySlotProps)

PropTypeDescription
defHotspotOverlayDefThe definition for this item.
selectedbooleanWhether this item's content is currently showing.
toggle() => voidToggle this item's content on/off.
close() => voidClose this item's content.
rectDOMRect | nullCurrent bounding rect (viewport-relative, with padding).
placementHotspotPlacementPreferred placement from the def.
anchorElHTMLElement | nullThe 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 active becomes 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 active is 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

PropTypeDefaultDescription
defHotspotOverlayDefRequired. Overlay definition for this item.
entryHotspotEntryRequired. Registry entry for this hotspot.
selectedbooleanWhether this item's content is showing.
colorstringundefinedCSS color override for pulse highlight.

Events

EventDescription
toggleEmitted when the item is clicked (mode 'info' only).
closeEmitted 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

PropTypeDefaultDescription
openbooleanfalseWhether the panel is visible.
placementHotspotPlacement"bottom"Preferred placement relative to the anchor. May be overridden by auto-flip.
anchorHTMLElement | nullnullThe element to anchor to — typically the overlay item's root.
marginnumber8Minimum distance (px) from viewport edges when clamping.
autoFlipbooleantrueWhen 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:

  1. Auto-flip — if autoFlip is 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.
  2. Viewport clamping — the panel's cross-axis position (horizontal for top/bottom, vertical for left/right) is clamped so the panel stays within viewport - margin on all edges.
  3. 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

ClassDescription
.carrot-hotspot-info-panelRoot panel container.
.carrot-hotspot-info-panel--{placement}Placement modifier (top/bottom/left/right).
.carrot-hotspot-info-panel__bodyContent area.
.carrot-hotspot-info-panel__arrowArrow tick element.
.carrot-hotspot-info-titleTitle 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");    // single

useHotspotsContext()

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>

Carrot