Self-instrumentation hook
useSkBlock gives a block its data-sk-* attributes and emits view, interact and convert events to any analytics sink already on the page (GA4 dataLayer, PostHog, Clarity, Shopify, rrweb).
hook
$
npx shadcn@latest add @storekit/sk-analyticssk-analytics
Demo pending
Live and interactive. Switch the brand in the frame header to see the same source under another token file.
Installation
$
npx shadcn@latest add @storekit/sk-analytics$
pnpm dlx shadcn@latest add @storekit/sk-analytics$
yarn dlx shadcn@latest add @storekit/sk-analytics$
bunx --bun shadcn@latest add @storekit/sk-analyticsSource
components/storekit/lib/sk-analytics.ts
78 lines
"use client"
import { useEffect, useRef } from "react"
/* Self-instrumenting components.
Every StoreKit block carries data-sk-section and data-sk-variant on its root.
This hook fires "view" once (50% visible) and lets a block report "interact"
and "convert" events. Events go to every sink that exists on the page:
window.dataLayer (GA4 / GTM), posthog, clarity, Shopify analytics, rrweb
custom events, and a window.__sk listener array for your own code.
Nothing is sent anywhere by default; sinks are detected, not installed. */
export type SkEvent = {
type: "view" | "interact" | "convert"
section: string
variant: string
pos?: number
exp?: string
label?: string
value?: number
ts: number
}
declare global {
interface Window {
dataLayer?: unknown[]
posthog?: { capture: (name: string, props?: Record<string, unknown>) => void }
clarity?: (...args: unknown[]) => void
Shopify?: { analytics?: { publish?: (name: string, payload?: unknown) => void } }
rrweb?: { record?: { addCustomEvent?: (tag: string, payload: unknown) => void } }
__sk?: Array<(e: SkEvent) => void>
}
}
export function skEmit(e: Omit<SkEvent, "ts">) {
if (typeof window === "undefined") return
const ev: SkEvent = { ...e, ts: Date.now() }
try { window.dataLayer?.push({ event: `sk_${ev.type}`, ...ev }) } catch {}
try { window.posthog?.capture(`sk_${ev.type}`, ev) } catch {}
try { window.clarity?.("event", `sk_${ev.type}_${ev.section}_${ev.variant}`) } catch {}
try { window.Shopify?.analytics?.publish?.(`sk_${ev.type}`, ev) } catch {}
try { window.rrweb?.record?.addCustomEvent?.("sk", ev) } catch {}
try { window.__sk?.forEach((fn) => fn(ev)) } catch {}
}
/** Attach to a block's root. Returns the data attributes and an `emit` helper.
The observer is (re)attached after every render until the root exists, so
roots that mount later (drawers, modals) still fire their view event. */
export function useSkBlock(section: string, variant: string, opts: { pos?: number; exp?: string } = {}) {
const ref = useRef<HTMLElement | null>(null)
const seen = useRef(false)
const observed = useRef<HTMLElement | null>(null)
const io = useRef<IntersectionObserver | null>(null)
useEffect(() => {
const el = ref.current
if (!el || seen.current || observed.current === el || typeof IntersectionObserver === "undefined") return
io.current?.disconnect()
observed.current = el
const obs = new IntersectionObserver(
(entries) => {
if (entries.some((x) => x.isIntersecting) && !seen.current) {
seen.current = true
skEmit({ type: "view", section, variant, pos: opts.pos, exp: opts.exp })
obs.disconnect()
}
},
{ threshold: 0.5 }
)
obs.observe(el)
io.current = obs
})
useEffect(() => () => io.current?.disconnect(), [])
const emit = (type: "interact" | "convert", label?: string, value?: number) =>
skEmit({ type, section, variant, pos: opts.pos, exp: opts.exp, label, value })
const attrs = { "data-sk-section": section, "data-sk-variant": variant, "data-sk-pos": opts.pos, "data-sk-exp": opts.exp } as const
return { ref, attrs, emit }
}
Usage
const { ref, attrs, emit } = useSkBlock("pdp", "buybox"); spread attrs on the root, call emit("convert", "add_to_cart", amount) on the action.