All patterns

Sentiment Tint Shift

Each row's edge and wash ease from neutral toward the tone the classifier detected.

aicalmelegantautomatic · finite · starter · ~1.5s
Variant

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.

280 lines · react + motion only
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.

Support queue triageTone detected on a messageReview classificationRisk level applied to a row

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Inbox
    Starred
    Drafts
    Archive
    Sent
    InboxNew
    Contract renewalPriya Raman · 10:14
    Q3 hiring planMarcus Bell · 09:02
    Venue confirmed for ThursdayDana Whitfield · Tue
    Invoice 4821 clearedBilling · Tue
    Weekly summaryReports · Mon
    Inbox

    Conversations pick up a tone colour on the row edge once assessed.

Related patterns