Sentiment Tint Shift
Each row's edge and wash ease from neutral toward the tone the classifier detected.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Sentiment Tint Shift
*
* Classification landing on a queue of messages. Each row's edge and
* wash ease from neutral toward the tone the model detected, one row
* after another, so the result arrives as a change of temperature rather
* than as a badge popping in.
*
* Self-contained: depends only on `react` and `motion`. Neutral surfaces
* are mixed from the inherited text color; only the sentiment hues are
* literal, because they carry meaning.
* Works with zero props; tune via `variant`, `messages`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SentimentTone = "positive" | "neutral" | "negative";
export type SentimentMessage = {
/** Stable key. */
id: string;
/** Who wrote it. */
author: string;
/** The message body, trimmed to a line or two. */
body: string;
/** What the classifier decided. */
tone: SentimentTone;
/** Label shown on the chip once classification lands. */
label: string;
};
export type SentimentTintShiftProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Rows to classify, in order. */
messages?: SentimentMessage[];
/** ms before the first row is classified. */
leadInMs?: number;
};
type VariantConfig = {
/** ms between one row settling and the next. */
cadenceMs: number;
/** Seconds the tint takes to arrive. */
tintSeconds: number;
/** Opacity of the background wash once settled. */
wash: number;
/** px the chip travels in from. */
chipTravel: number;
};
// Quality rule: nothing springs and nothing scales. A colour that
// overshoots reads as a flash, which is exactly the tell this pattern
// exists to avoid — the tint eases in and stops. Text never moves.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a wash, quick cadence. For a dense triage queue.
subtle: { cadenceMs: 240, tintSeconds: 0.4, wash: 0.55, chipTravel: 3 },
// A readable shift, one row after another. The all-purpose setting.
default: { cadenceMs: 340, tintSeconds: 0.55, wash: 0.85, chipTravel: 5 },
// A slower, fuller wash for a small set of highlighted conversations.
playful: { cadenceMs: 460, tintSeconds: 0.75, wash: 1, chipTravel: 8 },
};
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/** Semantic hues stay literal: they are the information, not chrome. */
const TONE_COLOR: Record<SentimentTone, string> = {
positive: "#2E9E6B",
neutral: "#6E8BFA",
negative: "#E5484D",
};
const DEFAULT_MESSAGES: SentimentMessage[] = [
{
id: "m1",
author: "Order 40912",
body: "The replacement arrived a day early and the packaging was perfect.",
tone: "positive",
label: "Positive",
},
{
id: "m2",
author: "Order 40915",
body: "Can you confirm which address the second parcel is going to?",
tone: "neutral",
label: "Neutral",
},
{
id: "m3",
author: "Order 40921",
body: "Third time I have written about this and nobody has picked it up.",
tone: "negative",
label: "Frustrated",
},
];
export default function SentimentTintShift({
variant = "default",
messages = DEFAULT_MESSAGES,
leadInMs = 520,
}: SentimentTintShiftProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const total = messages.length;
// Reduced motion: the classification is already applied on the first
// frame. The colour is the information; the easing is presentation.
const [settled, setSettled] = useState(0);
const done = reduceMotion ? total : settled;
useEffect(() => {
if (reduceMotion || settled >= total) return;
const timer = setTimeout(
() => setSettled((count) => count + 1),
settled === 0 ? leadInMs : cfg.cadenceMs
);
return () => clearTimeout(timer);
}, [settled, total, reduceMotion, leadInMs, cfg.cadenceMs]);
return (
<div
style={{
width: 320,
display: "flex",
flexDirection: "column",
gap: 8,
color: "inherit",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: 11,
fontWeight: 650,
letterSpacing: 0.2,
opacity: 0.5,
}}
>
<span>Inbox</span>
<span role="status">
{done < total ? `Reading ${done + 1} of ${total}` : "All read"}
</span>
</div>
{messages.map((message, index) => {
const classified = index < done;
const hue = TONE_COLOR[message.tone];
return (
<div
key={message.id}
style={{
position: "relative",
padding: "10px 12px 10px 14px",
borderRadius: 11,
border: `1px solid ${tone(11)}`,
background: tone(5),
overflow: "hidden",
}}
>
{/*
color-mix() results cannot be interpolated, so the shift is
done by crossfading two static layers rather than by
animating a colour: the neutral wash is always there and
the sentiment wash fades in over it.
*/}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: classified ? cfg.wash : 0 }}
transition={{
duration: reduceMotion ? 0 : cfg.tintSeconds,
ease: "easeOut",
}}
style={{
position: "absolute",
inset: 0,
background: `linear-gradient(90deg, color-mix(in srgb, ${hue} 16%, transparent), transparent 72%)`,
pointerEvents: "none",
}}
/>
{/* The edge is the clearest read of the two: a neutral rail
with the sentiment rail fading in on top of it. */}
<span
aria-hidden
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: tone(14),
}}
/>
<motion.span
aria-hidden
initial={false}
animate={{ opacity: classified ? 1 : 0 }}
transition={{
duration: reduceMotion ? 0 : cfg.tintSeconds,
ease: "easeOut",
}}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: hue,
}}
/>
<div
style={{
position: "relative",
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
}}
>
<span style={{ fontSize: 11, fontWeight: 650, opacity: 0.55 }}>
{message.author}
</span>
{/* The chip is confirmation, not the headline — it arrives
after the tint has already said it. */}
<motion.span
initial={false}
animate={{
opacity: classified ? 1 : 0,
y: classified || reduceMotion ? 0 : cfg.chipTravel,
}}
transition={{
duration: reduceMotion ? 0 : 0.24,
delay: classified && !reduceMotion ? cfg.tintSeconds * 0.4 : 0,
ease: "easeOut",
}}
style={{
flex: "0 0 auto",
padding: "1.5px 7px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.3,
color: hue,
background: `color-mix(in srgb, ${hue} 12%, transparent)`,
border: `1px solid color-mix(in srgb, ${hue} 32%, transparent)`,
}}
>
{message.label}
</motion.span>
</div>
<p
style={{
position: "relative",
margin: "4px 0 0",
fontSize: 12.5,
lineHeight: 1.45,
opacity: 0.78,
}}
>
{message.body}
</p>
</div>
);
})}
</div>
);
}About this pattern
Classification arriving on a queue that someone is already reading. Rather than popping a badge onto every row at once, each row's left rail and background wash ease from neutral toward its detected hue, one row after the next, and the label chip follows only after the colour has already said it. Because color-mix() results cannot be interpolated, the shift is a crossfade between two static layers rather than an animated colour — the neutral rail stays put and the sentiment rail fades in over it. Nothing scales, nothing springs, no row flashes.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Inbox
Conversations pick up a tone colour on the row edge once assessed.
Related patterns
- Regenerate SwapThe old answer dissolves upward while the new one rises into the space it left.
- Semantic Search RerankFirst-pass hits travel to their meaning-ranked places while each relevance bar grows to its score.
- Translate CrossfadeCopy hands over to its translation while the block eases to the new text's height.