react-sentiments
v0.1.3
Published
React hooks for detecting user frustration, confusion and engagement signals
Readme
react-sentiments
React hooks for detecting user frustration, confusion, and engagement signals in real time.
Most analytics tools tell you what users clicked. react-sentiments tells you how they felt frustrated, confused, hesitant, or engaged. So you can act on it in real time.
Installation
npm install react-sentiments
# or
yarn add react-sentiments
# or
pnpm add react-sentimentsRequirements: React 16.8+
Hooks
| Hook | What it detects |
|---|---|
| useRageClick | Rapid repeated clicks => frustration |
| useHesitation | Long hover before click => uncertainty |
| useDeadClick | Clicks on non-interactive elements => broken UI |
| useExitIntent | Mouse heading to browser chrome => about to leave |
| useEngagementTime | Active time on page (tab focused + not idle) |
| useScrollDepth | How far down the page they actually scrolled |
| useInputHesitation | Types, pauses, deletes => unsure what to enter |
| useCopyIntent | Selects or copies text => content engagement |
| useReread | Scrolls back to re-read a section => confused or interested |
| useSessionFrustration | Aggregated frustration score (0–100) from all signals |
Usage
useRageClick
Detects when a user clicks the same element rapidly, a classic sign of frustration.
import { useRageClick } from "react-sentiments";
function SubmitButton() {
const { onClick } = useRageClick({
threshold: 3, // clicks needed to trigger
timeWindow: 1000, // within this many ms
onRageClick: (count) => {
console.log(`Rage clicked ${count} times!`);
showHelpTooltip("Having trouble? Try refreshing the page.");
},
});
return <button onClick={onClick}>Submit</button>;
}Options
| Option | Type | Default | Description |
|---|---|---|---|
| threshold | number | 3 | Clicks needed to trigger |
| timeWindow | number | 1000 | Time window in ms |
| onRageClick | (count: number) => void | required | Fired when threshold is reached |
Returns: { onClick }
useHesitation
Detects when a user hovers over an element for too long before clicking, a signal of uncertainty.
import { useHesitation } from "react-sentiments";
function PricingButton() {
const { onMouseEnter, onMouseLeave, onClick } = useHesitation({
threshold: 2000, // ms of hover before it's hesitation
onHesitation: (dwellTime) => {
console.log(`User hesitated for ${dwellTime}ms`);
showPricingTooltip("This is our most popular plan!");
},
});
return (
<button {...{ onMouseEnter, onMouseLeave, onClick }}>
Buy Now — $49/mo
</button>
);
}Options
| Option | Type | Default | Description |
|---|---|---|---|
| threshold | number | 2000 | Dwell time in ms before hesitation |
| onHesitation | (dwellTime: number) => void | required | Fired on click if hovered too long |
Returns: { onMouseEnter, onMouseLeave, onClick }
useDeadClick
Detects clicks on non-interactive elements, the user thinks something should be clickable but it isn't.
import { useDeadClick } from "react-sentiments";
function App() {
useDeadClick({
scope: "#main-content", // only monitor this area
onDeadClick: (element) => {
console.log("Dead click on:", element.tagName, element.className);
analytics.track("dead_click", { element: element.outerHTML });
},
});
return <main id="main-content">...</main>;
}Options
| Option | Type | Default | Description |
|---|---|---|---|
| scope | string | "body" | CSS selector to scope monitoring |
| onDeadClick | (element: HTMLElement) => void | required | Fired on dead click |
useExitIntent
Detects when the user moves their mouse toward the top of the viewport, they're about to leave.
import { useExitIntent } from "react-sentiments";
function Page() {
useExitIntent({
threshold: 20, // px from top edge
once: true, // fire once per session
onExitIntent: () => {
showRetentionModal("Wait! Here's 10% off before you go.");
},
});
return <div>...</div>;
}Options
| Option | Type | Default | Description |
|---|---|---|---|
| threshold | number | 20 | Distance from top in px to trigger |
| once | boolean | true | Fire only once per page load |
| onExitIntent | () => void | required | Fired when exit intent detected |
useEngagementTime
Tracks how long a user is actively engaged, tab must be focused and user must not be idle.
import { useEngagementTime } from "react-sentiments";
function Article() {
const { engagementTime, isActive } = useEngagementTime({
idleTimeout: 30000, // stop counting after 30s of inactivity
});
// Send to analytics when user leaves
useEffect(() => {
return () => analytics.track("engagement", { seconds: engagementTime });
}, [engagementTime]);
return (
<div>
<article>...</article>
<p>Active for {engagementTime}s {isActive ? "🟢" : "⚪"}</p>
</div>
);
}Options
| Option | Type | Default | Description |
|---|---|---|---|
| idleTimeout | number | 30000 | Inactivity ms before stopping the counter |
Returns: { engagementTime, isActive }
useScrollDepth
Tracks the maximum scroll depth as a percentage, with optional milestone callbacks.
import { useScrollDepth } from "react-sentiments";
function BlogPost() {
const { scrollDepth } = useScrollDepth({
milestones: [25, 50, 75, 100],
onMilestone: (pct) => {
analytics.track("scroll_depth", { percentage: pct });
},
});
return (
<div>
<article>...</article>
<p>Read {scrollDepth}% of this post</p>
</div>
);
}Options
| Option | Type | Default | Description |
|---|---|---|---|
| milestones | number[] | [25, 50, 75, 100] | Percentages to fire callback at |
| onMilestone | (pct: number) => void | — | Fired when each milestone is crossed |
Returns: { scrollDepth }
useInputHesitation
Detects uncertainty in form fields, user types, long pause, then deletes.
import { useInputHesitation } from "react-sentiments";
function SignupForm() {
const { onChange } = useInputHesitation({
pauseThreshold: 2000,
onHesitation: ({ value, pauseDuration, deletedAfterPause }) => {
if (deletedAfterPause) {
showFieldHint("Not sure what to enter? Here's an example.");
}
analytics.track("input_hesitation", { field: "email", pauseDuration });
},
});
return <input type="email" onChange={onChange} placeholder="Email" />;
}Options
| Option | Type | Default | Description |
|---|---|---|---|
| pauseThreshold | number | 2000 | Pause in ms before hesitation fires |
| onHesitation | (info: InputHesitationInfo) => void | required | Fired on hesitation |
InputHesitationInfo
| Field | Type | Description |
|---|---|---|
| value | string | Field value at time of hesitation |
| pauseDuration | number | How long the pause was in ms |
| deletedAfterPause | boolean | Whether user deleted text after pausing |
Returns: { onChange }
useCopyIntent
Detects when a user selects text, and whether they followed through with an actual copy.
import { useCopyIntent } from "react-sentiments";
function CodeBlock({ code }: { code: string }) {
const { onMouseUp, onCopy } = useCopyIntent({
onSelect: (text) => analytics.track("text_selected", { text }),
onCopy: (text) => analytics.track("text_copied", { text }),
});
return <pre onMouseUp={onMouseUp} onCopy={onCopy}>{code}</pre>;
}Options
| Option | Type | Default | Description |
|---|---|---|---|
| onSelect | (text: string) => void | — | Fired when user selects text |
| onCopy | (text: string) => void | — | Fired when user copies text |
Returns: { onMouseUp, onCopy }
useReread
Detects when a user scrolls back to re-read a section, could mean confusion or high interest.
import { useReread } from "react-sentiments";
function ComplexSection() {
const { ref, rereadCount } = useReread({
threshold: 2,
onReread: (count) => {
if (count >= 2) showExplainerTooltip("Need a hand with this section?");
analytics.track("section_reread", { count });
},
});
return (
<section ref={ref}>
<h2>How our pricing works</h2>
<p>...</p>
</section>
);
}Options
| Option | Type | Default | Description |
|---|---|---|---|
| threshold | number | 2 | Number of rereads before firing callback |
| onReread | (count: number) => void | — | Fired when threshold is crossed |
Returns: { ref, rereadCount }
useSessionFrustration
Aggregates all frustration signals into a single score from 0–100. Use it alongside the other hooks.
import {
useSessionFrustration,
useRageClick,
useExitIntent,
useDeadClick,
} from "react-sentiments";
function App() {
const { score, signals, addSignal, reset } = useSessionFrustration();
useRageClick({ onRageClick: () => addSignal("rageClick") });
useExitIntent({ onExitIntent: () => addSignal("exitIntent") });
useDeadClick({ onDeadClick: () => addSignal("deadClick") });
useEffect(() => {
if (score >= 60) {
openLiveChat("Looks like you're having trouble — can we help?");
}
}, [score]);
return (
<div>
<p>Frustration score: {score}/100</p>
<p>Signals: {signals.join(", ")}</p>
<button onClick={reset}>Reset</button>
</div>
);
}Signal weights
| Signal | Weight |
|---|---|
| rageClick | 25 |
| exitIntent | 20 |
| deadClick | 15 |
| hesitation | 10 |
| inputHesitation | 10 |
| reread | 5 |
Returns: { score, signals, addSignal, reset }
Real world example — putting it all together
import {
useSessionFrustration,
useRageClick,
useExitIntent,
useDeadClick,
useScrollDepth,
useEngagementTime,
} from "react-sentiments";
function CheckoutPage() {
const { score, addSignal } = useSessionFrustration();
const { engagementTime } = useEngagementTime();
const { scrollDepth } = useScrollDepth({
onMilestone: (pct) => analytics.track("scroll", { pct }),
});
useRageClick({
onRageClick: (count) => {
addSignal("rageClick");
analytics.track("rage_click", { count });
},
});
useExitIntent({
onExitIntent: () => {
addSignal("exitIntent");
if (score > 40) showExitModal("Need help completing your order?");
},
});
useDeadClick({
scope: "#checkout-form",
onDeadClick: (el) => {
addSignal("deadClick");
console.warn("Dead click on:", el);
},
});
return (
<div>
<form id="checkout-form">...</form>
{/* Debug panel — remove in production */}
<pre>Score: {score} | Time: {engagementTime}s | Depth: {scrollDepth}%</pre>
</div>
);
}TypeScript
All hooks are fully typed. You can import option and return types directly:
import type {
UseRageClickOptions,
UseHesitationOptions,
InputHesitationInfo,
FrustrationSignal,
} from "react-sentiments";License
MIT
