Skip to content

Spotlight Overlay Component Code

Dependencies

This component requires:

  • Vue 3 with Composition API
  • useSpotlightOverlay composable for state management
  • Modern browsers with CSS clip-path support

Component Structure

The OSpotOverlay component consists of two main parts:

  1. OSpotOverlay.vue - The main component template and logic
  2. useSpotlightOverlay.js - The composable for state management and methods

Full Component Code

OSpotOverlay.vue

vue
<template>
	<div class="spotlight-overlay-wrapper" v-show="showFloatingInstructionBar" @click="resetInstructionBarConfig">
		<div ref="highlightAreaRef" class="highlight-area" :class="{ 'clip-path-animating': isClipPathAnimating }" :style="highlightArea">
			<div class="highlight-area-border" :style="highlightAreaBorderStyles"></div>
		</div>
		<section  tabindex="-1" class="floating-instruction-wrapper" :class="{'allow-background-interaction': barConfig.allowInteractionWithOtherElements}" :style="{...clipPathArea}" @blur="handleBlur">
			<div
				ref="floatingInstructionBar"
				class="pointer-icon-wrapper arrow-svg"
				:class="{
					'top-center': arrowPosition === 'top',
					'top-left': arrowPosition === 'topLeft',
					'top-right': arrowPosition === 'topRight',
					'top-extreme-right': arrowPosition === 'topExtremeRight',
					'top-extreme-left': arrowPosition === 'topExtremeLeft',
					'right-center': arrowPosition.startsWith('right'),
					'left-center': arrowPosition.startsWith('left'),
					'bottom-center': arrowPosition === 'bottom',
					'bottom-left': arrowPosition === 'bottomLeft',
					'bottom-right': arrowPosition === 'bottomRight',
					'bottom-extreme-right': arrowPosition === 'bottomExtremeRight',
					'bottom-extreme-left': arrowPosition === 'bottomExtremeLeft',
					'animating': isAnimating,
					'arrow-position-changed': arrowPosition !== previousArrowPosition
				}"
				:style="{ 
					...positionProperty,
					transition: isAnimating ? `all ${animationDuration}ms cubic-bezier(0.4, 0, 0.2, 1)` : 'none'
				}"
				@click.stop
			>
				<div class="instruction-bar-wrapper">
					<svg
						v-if="!barConfig.hideArrowIcon"
						class="straight-pointer"
						width="6"
						height="36"
						viewBox="0 0 6 36"
						fill="none"
						xmlns="http://www.w3.org/2000/svg"
					>
						<path
							d="M3 0.333333C1.52724 0.333333 0.333333 1.52724 0.333333 3C0.333333 4.47276 1.52724 5.66667 3 5.66667C4.47276 5.66667 5.66667 4.47276 5.66667 3C5.66667 1.52724 4.47276 0.333333 3 0.333333ZM2.5 3L2.5 36L3.5 36L3.5 3L2.5 3Z"
							fill="#0E0E0E"
						/>
					</svg>
	
					<svg
						v-if="!barConfig.hideArrowIcon"
						class="turned-pointer"
						width="17"
						height="26"
						viewBox="0 0 17 26"
						fill="none"
						xmlns="http://www.w3.org/2000/svg"
					>
						<path
							d="M0.833333 3C0.833333 4.47276 2.02724 5.66667 3.5 5.66667C4.97276 5.66667 6.16667 4.47276 6.16667 3C6.16667 1.52724 4.97276 0.333335 3.5 0.333334C2.02724 0.333334 0.833333 1.52724 0.833333 3ZM15 3L15 3.5L15 3ZM16 4L15.5 4L16 4ZM3.5 3.5L15 3.5L15 2.5L3.5 2.5L3.5 3.5ZM15.5 4L15.5 25.5L16.5 25.5L16.5 4L15.5 4ZM15 3.5C15.2761 3.5 15.5 3.72386 15.5 4L16.5 4C16.5 3.17157 15.8284 2.5 15 2.5L15 3.5Z"
							fill="#0E0E0E"
						/>
					</svg>
					<div class="instruction-bar">
						<div class="content-wrapper">
							<div v-if="barConfig.title" class="message">{{ barConfig.title }}</div>
							<div v-if="barConfig.button" class="cta-button" @click="barConfig.button.callback(barConfig)">{{ barConfig.button.label }}</div>
						</div>
						<div class="close-icon" @click="onCloseButtonClick">
							<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
								<rect width="20" height="20" rx="3" fill="#F1F2F3"/>
								<path d="M13.3346 13.3346L10.0013 10.0013M10.0013 10.0013L6.66797 6.66797M10.0013 10.0013L13.3347 6.66797M10.0013 10.0013L6.66797 13.3347" stroke="#3A393A" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
							</svg>
						</div>
					</div>
				</div>
			</div>
		</section>
	</div>
</template>

<script setup>
import { computed, watch, onMounted, nextTick, ref } from 'vue'
import useSpotlightOverlay from '../composables/useSpotlightOverlay'

// Use the spotlight overlay composable
const {
	positionX,
	positionY,
	previousPositionX,
	previousPositionY,
	arrowPosition,
	previousArrowPosition,
	showFloatingInstructionBar,
	barConfig,
	highlightX,
	highlightY,
	clipPathX,
	clipPathY,
	clipPathHeight,
	clipPathWidth,
	highlightAreaBorderStyles,
	isAnimating,
	animationDuration,
	resetInstructionBarConfig
} = useSpotlightOverlay()

// Template refs
const floatingInstructionBar = ref(null)
const highlightAreaRef = ref(null)

// Animation state
const isClipPathAnimating = ref(false)

// Watch for arrow position changes
watch(arrowPosition, (newPosition, oldPosition) => {
	if (showFloatingInstructionBar.value && newPosition !== oldPosition) {
		// Arrow position changed, smooth transition will be handled by CSS
		nextTick(() => {
			focusBar()
		})
	}
})

// Watch for clip path changes to trigger close-in animation
watch([clipPathX, clipPathY, clipPathWidth, clipPathHeight], ([newX, newY, newWidth, newHeight], [oldX, oldY, oldWidth, oldHeight]) => {
	if (showFloatingInstructionBar.value && 
		(oldX !== 0 || oldY !== 0 || oldWidth !== 0 || oldHeight !== 0) && 
		(newX !== oldX || newY !== oldY || newWidth !== oldWidth || newHeight !== newHeight)) {
		// Clip path changed, trigger close-in animation
		triggerClipPathAnimation()
	}
})

// Computed properties
const positionProperty = computed(() => {
	const remSize = remToPixel.value
	const minX = 0* remSize
	const minY = 0 * remSize
	let positionXValue = (Math.max(minX, positionX.value)) + "px"
	let positionYValue = Math.max(minY, positionY.value) + "px"
	
	if (barConfig.value.allowExtremeLeft) {
		positionXValue = Math.max(20, positionX.value) + "px"
	}
	
	if (arrowPosition.value === "left") {
		return {
			left: `calc(${positionXValue} + 16px)`,
			top: `calc(${positionYValue})`,
			transform: "translate(100%, -50%)",
		}
	} else if (arrowPosition.value === "top") {
		return {
			left: `calc(${positionXValue})`,
			top: `calc(${positionYValue} + 16px)`,
			transform: "translate(-50%, 0)",
		}
	} else if (arrowPosition.value === "topRight") {
		return {
			left: `calc(${positionXValue})`,
			top: `calc(${positionYValue} + 16px)`,
			transform: "translate(-50%, 0)",
		}
	} else if (arrowPosition.value === "topExtremeRight") {
		return {
			left: `calc(${positionXValue})`,
			top: `calc(${positionYValue} + 16px)`,
			transform: "translate(-50%, 0)",
		}
	} else if (arrowPosition.value === "topLeft") {
		return {
			left: `calc(${positionXValue})`,
			top: `calc(${positionYValue} + 16px)`,
			transform: "translate(-50%, 0)",
		}
	} else if (arrowPosition.value === "topExtremeLeft") {
		return {
			left: `calc(${positionXValue})`,
			top: `calc(${positionYValue} + 16px)`,
			transform: "translate(-50%, 0)",
		}
	} else if (arrowPosition.value === "right") {
		return {
			left: `calc(${positionXValue} - 16px)`,
			top: `calc(${positionYValue})`,
			transform: "translate(-200%, -50%)",
		}
	} else if (arrowPosition.value === "bottom") {
		return {
			left: `calc(${positionXValue})`,
			top: `calc(${positionYValue} - 24px)`,
			transform: "translate(-50%, -50%)",
		}
	} else if (arrowPosition.value === "bottomRight") {
		return {
			left: `calc(${positionXValue})`,
			top: `calc(${positionYValue} - 24px)`,
			transform: "translate(-50%, -50%)",
		}
	} else if (arrowPosition.value === "bottomExtremeRight") {
		return {
			left: `calc(${positionXValue})`,
			top: `calc(${positionYValue} - 16px)`,
			transform: "translate(-50%, -50%)",
		}
	} else if (arrowPosition.value === "bottomLeft") {
		return {
			left: `calc(${positionXValue})`,
			top: `calc(${positionYValue} - 24px)`,
			transform: "translate(-50%, -50%)",
		}
	} else if (arrowPosition.value === "bottomExtremeLeft") {
		return {
			left: `calc(${positionXValue})`,
			top: `calc(${positionYValue} - 16px)`,
			transform: "translate(-50%, -50%)",
		}
	}
	
	return {
		left: `calc(${positionXValue})`,
		top: `calc(${positionYValue})`,
	}
})

const highlightArea = computed(() => {
	return {
		left: `${clipPathX.value}px`,
		top: `${clipPathY.value}px`,
		width: `${clipPathWidth.value}px`,
		height: `${clipPathHeight.value}px`,
	}
})

const clipPathArea = computed(() => {
	if (barConfig.value.allowInteractionWithOtherElements) {
		return {}
	}

	const rectTop = (clipPathY.value / window.innerHeight) * 100
	const rectBottom = ((clipPathY.value + clipPathHeight.value) / window.innerHeight) * 100
	const rectLeft = (clipPathX.value / window.innerWidth) * 100
	const rectRight = ((clipPathX.value + clipPathWidth.value) / window.innerWidth) * 100

	return {
		"clip-path": `polygon(
			0% 0%, 
			0% 100%, 
			${rectLeft}% 100%, 
			${rectLeft}% ${rectTop}%, 
			${rectRight}% ${rectTop}%, 
			${rectRight}% ${rectBottom}%, 
			${rectLeft}% ${rectBottom}%, 
			${rectLeft}% 100%, 
			100% 100%, 
			100% 0%
		)`
	}
})

// Methods
const calculatePosition = () => {
	const element = document.getElementById(barConfig.value.elementId)
	if (!element) return

	const rect = element.getBoundingClientRect()
	const remSize = remToPixel.value
	const minX = 0 * remSize
	const minY = 0 * remSize
	const gap = remSize

	// Ensure minimum gaps
	positionX.value = Math.max(minX, rect.left + gap)
	positionY.value = Math.max(minY, rect.top + gap)
}

const onCloseButtonClick = () => {
	if (barConfig.value.closeButtonCallBack) {
		barConfig.value.closeButtonCallBack()
	}
	resetInstructionBarConfig()
}

const onClickHighlight = () => {
	if (barConfig.value.button && barConfig.value.button.callback) {
		barConfig.value.button.callback(barConfig.value)
	}
}

const handleBlur = ({ relatedTarget }) => {
	let bar = floatingInstructionBar.value
	if (bar && (bar.contains(relatedTarget) || bar.isEqualNode(relatedTarget))) {
		return
	}
	
	if (barConfig.value.onBlur) {
		barConfig.value.onBlur(barConfig.value)
		resetInstructionBarConfig()
	}
}

const focusBar = () => {
	nextTick(() => {
		// Focus the popup
		const popup = floatingInstructionBar.value
		if (popup) {
			popup.focus()
		}
	})
}

const triggerClipPathAnimation = () => {
	if (!highlightAreaRef.value) return
	
	// Start animation
	isClipPathAnimating.value = true
	
	// Reset animation after duration
	setTimeout(() => {
		isClipPathAnimating.value = false
	}, 600) // 600ms animation duration
}

// Lifecycle hooks
onMounted(() => {
	calculatePosition()
})
</script>

<style lang="scss" scoped>
* {
	box-sizing: border-box;
}

.spotlight-overlay-wrapper {
	pointer-events: none;
	box-sizing: border-box;
	position: fixed;
	width: 100%;
	height: 100%;
	top: 0px;
	bottom: 0px;
	left: 0px;
	right: 0px;
	
	.highlight-area {
		width: 0;
		height: 0;
		pointer-events: none;
		display: flex;
		justify-content: center;
		align-items: center;
		background: transparent;
		border: none;
		background: transparent;
		position: absolute;
		transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
		
		&.clip-path-animating {
			animation: closeInAnimation 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
		}
		
		.highlight-area-border {
			display: flex;
			border: 9999px solid rgba(35, 33, 33, 0.7);
			width: 100%;
			height: 100%;
			min-width: 100%;
			min-height: 100%;
			box-sizing: content-box;
			background: transparent;
			transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
		}
	}
}

.floating-instruction-wrapper {
	pointer-events: all;
	box-sizing: border-box;
	position: fixed;
	width: 100%;
	height: 100%;
	top: 0px;
	bottom: 0px;
	left: 0px;
	right: 0px;
}

.instruction-bar {
	pointer-events: all;
	display: grid;
	grid-template-columns: 1fr max-content;
	align-items: center;
	gap: 0.5rem;
	padding: 0.6875rem 0.5rem 0.625rem 0.75rem;
	border-radius: 8px;
	border: 1px solid gray;
	background: #ffffff;
	box-shadow: 3px 0px 0px 0px orange inset, 0px 6px 12px 0px rgba(0, 0, 0, 0.25);
	width: 18rem;
	
	// Smooth transitions for content changes
	transition: opacity 0.2s ease-in-out;
	
	.content-wrapper {
		display: grid;
		width: 100%;
		align-items: center;
		grid-template-columns: 1fr max-content;
		gap: 0.125rem;
		
		.message {
			font-size: .75rem;
			font-weight: 600;
			color: black;
		}
		
		.cta-button {
			display: flex;
			padding: .5rem .625rem;
			justify-content: center;
			align-items: center;
			gap: .625rem;
			border-radius: 3px;
			background: linear-gradient(90deg, rgba(255, 89, 0, 0.755) 0%, rgb(255, 140, 0) 100%);
			color: #fefefe;
			font-size: .6875rem;
			font-weight: 600;
			cursor: pointer;
			box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.30);
		}
	}
	
	.close-icon {
		display: flex;
		justify-content: center;
		align-items: center;
		width: 1rem;
		height: 1rem;
		cursor: pointer;
		
		svg {
			width: 1rem !important;
			height: 1rem !important;
		}
	}
}

.pointer-icon-wrapper {
	position: absolute;
	
	&.animating {
		will-change: transform, left, top;
	}
	
	&.arrow-position-changed {
		.instruction-bar {
			transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
		}
		
		.straight-pointer,
		.turned-pointer {
			transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
		}
	}
	
	.instruction-bar-wrapper {
		position: relative;
		
		.instruction-bar {
			position: absolute;
		}
	}
	
	&.arrow-svg {
		&.top-center {
			.instruction-bar, .celebration-gif-wrapper {
				left: 50%;
				top: 100%;
				transform: translateX(-50%) translateY(0%);
			}
		}
		
		&.top-right {
			.instruction-bar, .celebration-gif-wrapper {
				left: 50%;
				top: 100%;
				transform: translateX(calc(20px - 100%)) translateY(0%);
			}
		}
		
		&.top-extreme-right {
			.instruction-bar, .celebration-gif-wrapper {
				left: 50%;
				top: 100%;
				transform: translateX(calc(10px - 100%)) translateY(0%);
			}
			svg {
				transform: rotateY(180deg);
			}
		}
		
		&.top-left {
			.instruction-bar, .celebration-gif-wrapper {
				left: 50%;
				top: 100%;
				transform: translateX(-20px) translateY(0%);
			}
		}
		
		&.top-extreme-left {
			.instruction-bar, .celebration-gif-wrapper {
				left: 50%;
				top: 100%;
				transform: translateX(-10px) translateY(0%);
			}
		}

		&.left-center {
			.instruction-bar, .celebration-gif-wrapper {
				left: 100%;
				top: 50%;
				transform: translateX(0%) translateY(-50%);
			}
			svg {
				transform: rotate(-90deg);
			}
		}

		&.right-center {
			.instruction-bar, .celebration-gif-wrapper {
				left: 0;
				top: 50%;
				transform: translateX(-100%) translateY(-50%);
			}
			svg {
				transform: rotate(90deg);
			}
		}

		&.bottom-center {
			.instruction-bar, .celebration-gif-wrapper {
				left: 50%;
				top: 0%;
				transform: translateX(-50%) translateY(-100%);
			}
			svg {
				transform: rotate(180deg);
			}
		}

		&.bottom-left {
			.instruction-bar, .celebration-gif-wrapper {
				left: 50%;
				top: 0%;
				transform: translateX(-20px) translateY(-100%);
			}
			svg {
				transform: rotate(180deg);
			}
		}
		
		&.bottom-extreme-left {
			.instruction-bar, .celebration-gif-wrapper {
				left: 50%;
				top: 0%;
				transform: translateX(-10px) translateY(-100%);
			}
			svg {
				transform: rotateX(180deg);
			}
		}

		&.bottom-right {
			.instruction-bar, .celebration-gif-wrapper {
				left: 50%;
				top: 0%;
				transform: translateX(calc(20px - 100%)) translateY(-100%);
			}
			svg {
				transform: rotate(180deg);
			}
		}
		
		&.bottom-extreme-right {
			.instruction-bar, .celebration-gif-wrapper {
				left: 0%;
				top: 0%;
				transform: translateX(calc(10px - 100%)) translateY(-100%);
			}
			svg {
				transform: rotate(180deg);
			}
		}
	}
	
	.straight-pointer {
		display: flex;
	}
	
	.turned-pointer {
		display: none;
	}
	
	&.top-extreme-right,
	&.top-extreme-left,
	&.bottom-extreme-right,
	&.bottom-extreme-left {
		.straight-pointer {
			display: none;
		}
		.turned-pointer {
			display: flex;
		}
	}
}

@keyframes closeInAnimation {
  0% {
    transform: scale(2);
    opacity: 1;
  }
  50% {
    transform: scale(1.5);
    opacity: 1;
  }
  100% {
    transform: scale(1);
    opacity: 1;
  }
}

.allow-background-interaction {
	pointer-events: none !important;
	background: transparent !important;
}

// Animation keyframes for smooth movement
@keyframes slideIn {
	from {
		opacity: 0;
		transform: translateY(-10px);
	}
	to {
		opacity: 1;
		transform: translateY(0);
	}
}

@keyframes fadeIn {
	from {
		opacity: 0;
	}
	to {
		opacity: 1;
	}
}

// Apply smooth entrance animation
.pointer-icon-wrapper {
	animation: slideIn 0.3s ease-out;
	
	.instruction-bar {
		animation: fadeIn 0.2s ease-out;
	}
}

// Enhanced transition for position changes
.pointer-icon-wrapper.animating {
	transform-origin: center;
	
	.instruction-bar {
		transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
	}
}
</style>

useSpotlightOverlay.js

javascript
import { ref, reactive, computed } from 'vue'

// Initial state
const createInitialState = () => ({
	positionX: -100000,
	positionY: -100000,
	previousPositionX: -100000,
	previousPositionY: -100000,
	arrowPosition: 'top',
	previousArrowPosition: 'top',
	showFloatingInstructionBar: false,
	barConfig: {},
	highlightX: -1,
	highlightY: -1,
	highlightRadius: 0,
	clipPathX: 0,
	clipPathY: 0,
	clipPathHeight: 10000,
	clipPathWidth: 10000,
	highlightedElements: [],
	showRenderReadyInstructionBar: false,
	highlightAreaBorderStyles: {
		borderRadius: '0',
		borderWidth: '0',
	},
	isAnimating: false,
	animationDuration: 300 // milliseconds
})

// Reactive state
const state = reactive(createInitialState())

// Utility functions
const calculateHighlightPosition = (rect) => {
	const highlightX = rect.left + rect.width / 2
	const highlightY = rect.top + rect.height / 2
	const highlightRadius = Math.max(rect.width / 2, rect.height / 2)
	return { highlightX, highlightY, highlightRadius }
}

const calculateClipPathPosition = (rect) => {
	const clipPathX = rect.left
	const clipPathY = rect.top
	const clipPathHeight = rect.height
	const clipPathWidth = rect.width
	return { clipPathX, clipPathY, clipPathHeight, clipPathWidth }
}

const calculateArrowPosition = (rect, arrowPosition) => {
	let positionX = rect.left + rect.width / 2
	let positionY = rect.top + rect.height / 2

	if (arrowPosition.startsWith('top')) {
		positionY = rect.bottom
	} else if (arrowPosition.startsWith('bottom')) {
		positionY = rect.top
	} else if (arrowPosition === 'right') {
		positionX = rect.left
	} else if (arrowPosition === 'left') {
		positionX = rect.right
	} else if (arrowPosition === 'leftTop') {
		positionY = rect.top + 16
		positionX = rect.right
	} else if (arrowPosition === 'leftBottom') {
		positionY = rect.bottom - 16
		positionX = rect.right
	} else if (arrowPosition === 'RightTop') {
		positionY = rect.top + 16
		positionX = rect.left
	} else if (arrowPosition === 'RightBottom') {
		positionY = rect.bottom - 16
		positionX = rect.left
	}

	return { positionX, positionY }
}

// Actions
const setPosition = (data) => {
	// Store previous position before updating
	state.previousPositionX = state.positionX
	state.previousPositionY = state.positionY
	
	state.positionX = data.positionX
	state.positionY = data.positionY
}

const setPositionWithAnimation = (data) => {
	// Store previous position before updating
	state.previousPositionX = state.positionX
	state.previousPositionY = state.positionY
	
	// Set animation flag
	state.isAnimating = true
	
	// Update position
	state.positionX = data.positionX
	state.positionY = data.positionY
	
	// Clear animation flag after duration
	setTimeout(() => {
		state.isAnimating = false
	}, state.animationDuration)
}

const setHighlight = (data) => {
	state.highlightX = data.highlightX
	state.highlightY = data.highlightY
	state.highlightRadius = data.highlightRadius
}

const setClipPath = (data) => {
	state.clipPathX = data.clipPathX
	state.clipPathY = data.clipPathY
	state.clipPathHeight = data.clipPathHeight
	state.clipPathWidth = data.clipPathWidth
}

const addHighlightedElements = ({ elementId, className }) => {
	// Add element to highlighted elements array
	state.highlightedElements.push({ elementId, className })
	// Apply the className to the element by elementId
	const element = document.getElementById(elementId)
	if (element) {
		element.classList.add(className)
	}
}

const resetHighlightedElements = () => {
	// Reset the array and remove the class from all highlighted elements
	state.highlightedElements.forEach(({ elementId, className }) => {
		const element = document.getElementById(elementId)
		if (element) {
			element.classList.remove(className)
		}
	})
	// Clear the highlighted elements array
	state.highlightedElements = []
}

const setInstructionBarConfig = (data) => {
	// Store previous arrow position
	state.previousArrowPosition = state.arrowPosition
	
	if (data.position) {
		// Use animation if the bar is already visible and position is changing
		if (state.showFloatingInstructionBar && 
			(state.positionX !== data.position.positionX || state.positionY !== data.position.positionY)) {
			setPositionWithAnimation(data.position)
		} else {
			setPosition(data.position)
		}
	}

	if (data.clipPath) {
		setClipPath(data.clipPath)
	} else {
		setClipPath({
			clipPathX: 0,
			clipPathY: 0,
			clipPathHeight: 0,
			clipPathWidth: 0,
		})
	}

	if (data.highlight) {
		setHighlight(data.highlight)
	} else {
		setHighlight({ highlightX: -1, highlightY: -1, highlightRadius: 0 })
	}

	state.arrowPosition = data.arrowPosition
	state.showFloatingInstructionBar = data.isVisible
	state.barConfig = { ...data.config, elementId: data.elementId }
}

const resetInstructionBarConfig = () => {
	setPosition({
		positionX: -100000,
		positionY: -100000,
		previousPositionX: -100000,
		previousPositionY: -100000,
	})

	state.showFloatingInstructionBar = false
	state.barConfig = {}
	state.clipPathX = 0,
	state.clipPathY = 0,
	state.clipPathHeight = 10000,
	state.clipPathWidth = 10000,
	resetHighlightedElements()
}

const setHighlightAreaBorderStyles = (data) => {
	state.highlightAreaBorderStyles = {
		...createInitialState().highlightAreaBorderStyles,
		...data
	}
}

const setInstructionBarConfigByElementId = (data) => {
	if (!data || !data.elementId) return

	let payload = { ...data }

	const element = document.getElementById(data.elementId)
	if (!element) return
	
	// Extract border styles from the element and set them in the state
	const computedStyles = window.getComputedStyle(element)
	const elementBorderRadius = computedStyles.getPropertyValue('border-radius')
	let borderRadius = '0'
	if(elementBorderRadius.includes('%')) {
		borderRadius = `calc(${elementBorderRadius})`
	} else {
		borderRadius = `calc(${elementBorderRadius} + 9999px)`
	}
	setHighlightAreaBorderStyles({
		borderRadius,
		borderWidth: '9999px',
	})

	const rect = element.getBoundingClientRect()

	const { highlightX, highlightY, highlightRadius } = calculateHighlightPosition(rect)
	const { positionX, positionY } = calculateArrowPosition(rect, data.arrowPosition)
	const { clipPathX, clipPathY, clipPathHeight, clipPathWidth } = calculateClipPathPosition(rect)

	payload.position = { positionX, positionY }
	payload.highlight = { highlightX, highlightY, highlightRadius }
	payload.clipPath = { clipPathX, clipPathY, clipPathHeight, clipPathWidth }
	highlightElement({ elementId: data.elementId, className: (data.elementHighlightClassName || 'tour-highlight') })

	if (payload.config && payload.config.button) {
		let buttonCallback = () => {}

		if (payload.config.button.callback) {
			buttonCallback = payload.config.button.callback
		}

		payload.config.button.callback = () => {
			element.click()
			buttonCallback()
			resetInstructionBarConfig()
		}
	}

	if (data.elementToHighLightId) {
		resetHighlightedElements()

		const elementToHighlight = document.getElementById(data.elementToHighLightId)
		if (elementToHighlight) {
			const highlightRect = elementToHighlight.getBoundingClientRect()

			const { highlightX, highlightY, highlightRadius } = calculateHighlightPosition(highlightRect)
			const { positionX, positionY } = calculateArrowPosition(highlightRect, data.arrowPosition)

			payload.position = { positionX, positionY }
			payload.highlight = { highlightX, highlightY, highlightRadius }
			highlightElement({ elementId: data.elementToHighLightId, className: (data.elementHighlightClassName || 'tour-highlight') })
		}
	}

	setInstructionBarConfig(payload)
}

const highlightElement = ({ elementId, className }) => {
	addHighlightedElements({ elementId, className })
}

// Computed properties (getters)
const getters = {
	positionX: computed(() => state.positionX),
	positionY: computed(() => state.positionY),
	previousPositionX: computed(() => state.previousPositionX),
	previousPositionY: computed(() => state.previousPositionY),
	arrowPosition: computed(() => state.arrowPosition),
	previousArrowPosition: computed(() => state.previousArrowPosition),
	showFloatingInstructionBar: computed(() => state.showFloatingInstructionBar),
	barConfig: computed(() => state.barConfig),
	highlightX: computed(() => state.highlightX),
	highlightY: computed(() => state.highlightY),
	highlightRadius: computed(() => state.highlightRadius),
	clipPathX: computed(() => state.clipPathX),
	clipPathY: computed(() => state.clipPathY),
	clipPathHeight: computed(() => state.clipPathHeight),
	clipPathWidth: computed(() => state.clipPathWidth),
	highlightedElements: computed(() => state.highlightedElements),
	showRenderReadyInstructionBar: computed(() => state.showRenderReadyInstructionBar),
	highlightAreaBorderStyles: computed(() => state.highlightAreaBorderStyles),
	isAnimating: computed(() => state.isAnimating),
	animationDuration: computed(() => state.animationDuration)
}

// Main composable function
export default function useSpotlightOverlay() {
	return {
		// State
		state,
		
		// Getters
		...getters,
		
		// Actions
		setInstructionBarConfig,
		resetInstructionBarConfig,
		setInstructionBarConfigByElementId,
		highlightElement,
		
		// Utility functions
		setPosition,
		setPositionWithAnimation,
		setHighlight,
		setClipPath,
		addHighlightedElements,
		resetHighlightedElements
	}
}

Key Implementation Details

Component Architecture

  1. Template Structure:

    • spotlight-overlay-wrapper: Main container with overlay functionality
    • highlight-area: The dimmed background with cutout for highlighted element
    • floating-instruction-wrapper: Container for the instruction bar
    • pointer-icon-wrapper: Arrow and instruction bar positioning
  2. State Management:

    • Uses Vue 3 Composition API with reactive state
    • Centralized state management through the composable
    • Computed properties for derived state
  3. Positioning System:

    • Dynamic calculation based on target element's bounding rectangle
    • Support for 12 different arrow positions
    • Responsive positioning that adapts to screen boundaries

Animation System

  1. CSS Transitions:

    • Smooth position changes with cubic-bezier easing
    • Clip path animations for highlight area changes
    • Arrow position transitions
  2. Keyframe Animations:

    • closeInAnimation: Scale effect for highlight area
    • slideIn: Entrance animation for instruction bar
    • fadeIn: Content fade effects

Accessibility Features

  1. Focus Management:

    • Proper focus handling for keyboard navigation
    • Blur event handling for focus loss
    • Tab index management
  2. Keyboard Support:

    • Full keyboard navigation support
    • Escape key handling (can be implemented)
    • Focus trapping within instruction bar

Performance Optimizations

  1. Efficient Updates:

    • Watchers only trigger when necessary
    • Computed properties for derived state
    • Minimal DOM manipulations
  2. Memory Management:

    • Proper cleanup of highlighted elements
    • Event listener cleanup
    • Animation timeout management

Browser Support

  • Modern browsers with CSS clip-path support
  • CSS Grid support required
  • ES6+ JavaScript features
  • Vue 3 Composition API

Dependencies

  • Vue 3 with Composition API
  • Modern CSS features (clip-path, CSS Grid, CSS Custom Properties)
  • No external JavaScript dependencies