diff --git a/.changeset/chat-feedback-component.md b/.changeset/chat-feedback-component.md
new file mode 100644
index 0000000000..4cfc9836df
--- /dev/null
+++ b/.changeset/chat-feedback-component.md
@@ -0,0 +1,20 @@
+---
+'@razorpay/blade': minor
+---
+
+feat(ChatFeedback): add `ChatFeedback` — a four-point rating flow for conversational surfaces: mood, follow-up tags, and an optional free-text comment. Web only for now; the native counterpart throws until it is implemented
+
+`moodIcons` is required: Blade ships no artwork for the scale yet, so each point takes a glyph of your own — a product's icon set, or plain emoji characters. When a designed set lands, `moodIcons` becomes optional and a `moodScale` prop picks between sets, which is a non-breaking direction of travel
+
+fix(ChatFeedback): hold the thank-you step for 1.3s before dismissing. It previously ran on `motion.delay.xgentle` (960ms), and on a strip that is also fading out the confirmation was gone before it registered
+
+feat(ChatFeedback): closing copy now follows the mood — `moodConfig[mood].thanksLabel`, with defaults that acknowledge rather than celebrate at the unhappy end. A top-level `thanksLabel` still speaks for every mood
+
+feat(ChatFeedback): add `onTagsChange`, `controlsRef` and `isSubmitHidden`, so a surrounding surface can collect the free-text comment in an input of its own, submit the flow from its own control, and hide the flow's tick while it does
+
+**Breaking within this unreleased component:** the free-text `comment` step and its `Add more feedback` link are removed, along with `addCommentLabel` and `commentPlaceholder`. Free text is now the host's to collect — `ChatFeedbackProps.comment` folds it into the submit payload. `ChatFeedbackStep` loses `'comment'`
+
+fix(ChatFeedback): report every change to the tag selection through `onTagsChange`, not only the ones made in the chip group. Picking a new mood and going back both clear the tags, and were previously silent — so a host mirroring the selection acted on tags that no longer existed
+
+feat(ChatFeedback): ship an animated four-point icon set as the default artwork. Each face is static at rest and animates only while its button is hovered, focused or selected
+
diff --git a/packages/blade/src/components/ChatFeedback/ChatFeedback.native.tsx b/packages/blade/src/components/ChatFeedback/ChatFeedback.native.tsx
new file mode 100644
index 0000000000..4c9dc8f63b
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/ChatFeedback.native.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import type { ChatFeedbackProps } from './types';
+import { Text } from '~components/Typography';
+import { throwBladeError } from '~utils/logger';
+import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects';
+import { MetaConstants } from '~utils/metaAttribute';
+
+const _ChatFeedback = (_props: ChatFeedbackProps): React.ReactElement => {
+ throwBladeError({
+ message: 'ChatFeedback is not yet implemented for native.',
+ moduleName: 'ChatFeedback',
+ });
+
+ return ChatFeedback is not available for Native mobile apps.;
+};
+
+const ChatFeedback = assignWithoutSideEffects(_ChatFeedback, {
+ componentId: MetaConstants.ChatFeedback,
+ displayName: 'ChatFeedback',
+});
+
+export { ChatFeedback };
diff --git a/packages/blade/src/components/ChatFeedback/ChatFeedback.test.stories.tsx b/packages/blade/src/components/ChatFeedback/ChatFeedback.test.stories.tsx
new file mode 100644
index 0000000000..b902d29a20
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/ChatFeedback.test.stories.tsx
@@ -0,0 +1,114 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+/* eslint-disable import/no-extraneous-dependencies */
+import type { StoryFn } from '@storybook/react-vite';
+import { within, waitFor, userEvent, expect } from 'storybook/test';
+import React from 'react';
+import { ChatFeedback } from './index';
+import { Box } from '~components/Box';
+
+/**
+ * Interaction tests for the rating flow.
+ *
+ * These run in a real browser because the things worth guarding are geometric: whether a tap
+ * lands on the point it looks like it lands on, and whether a selection is visible at all.
+ * Neither is observable in jsdom, which has no layout and paints nothing.
+ */
+export default {
+ title: 'Components/ChatFeedback/ChatFeedback Interaction Tests',
+ component: ChatFeedback,
+ parameters: {
+ controls: { disable: true },
+ a11y: { disable: false },
+ chromatic: { disableSnapshot: true },
+ },
+};
+
+/**
+ * Emoji rather than the shipped SVGs on purpose.
+ *
+ * Untintable artwork is the harder case: colour does nothing to it and it has no filled twin, so
+ * these stories prove the selected state survives on the worst input rather than the best.
+ */
+const feedbackIcons = {
+ 'very-dissatisfied': 😢,
+ dissatisfied: 😕,
+ satisfied: 🙂,
+ 'very-satisfied': 😍,
+};
+
+const Flow = (): React.ReactElement => (
+
+
+
+);
+
+/**
+ * Each target must contain the point a user aims at, and stop before its neighbour's.
+ *
+ * The buttons are 32px around a 20px glyph and butted together, so there is no dead space between
+ * them: a tap a few pixels wide of a face lands on the next one and records the *adjacent* rating.
+ * On a four-point scale that is a wrong answer, not a near miss — so this checks the centre and
+ * both inner edges resolve to the button they appear to belong to.
+ */
+export const HitTargetsResolveToTheRightMood: StoryFn = (): React.ReactElement => ;
+
+HitTargetsResolveToTheRightMood.play = async ({ canvasElement }) => {
+ const { getByRole } = within(canvasElement);
+ const button = getByRole('radio', { name: 'Good' });
+
+ await waitFor(() => expect(button).toBeVisible());
+
+ const box = button.getBoundingClientRect();
+ const points = [
+ { x: box.left + box.width / 2, y: box.top + box.height / 2 },
+ { x: box.left + 2, y: box.top + box.height / 2 },
+ { x: box.right - 2, y: box.top + box.height / 2 },
+ ];
+
+ points.forEach(({ x, y }) => {
+ expect(button.contains(document.elementFromPoint(x, y))).toBe(true);
+ });
+};
+
+/**
+ * Selection has to be visible even when the glyph cannot carry it.
+ *
+ * Supplied artwork may be untintable and has no filled twin, so recolouring the icon does nothing.
+ * The button's background is the cue that survives — without it, a 12% scale would be the only
+ * sign a rating registered.
+ */
+export const SelectionIsVisibleWithUntintableArtwork: StoryFn = (): React.ReactElement => ;
+
+SelectionIsVisibleWithUntintableArtwork.play = async ({ canvasElement }) => {
+ const { getByRole } = within(canvasElement);
+ const button = getByRole('radio', { name: 'Terrible' });
+ // The disc is a pseudo-element, so this is the only place it can be read — a real browser.
+ const discOpacity = (): string => window.getComputedStyle(button, '::before').opacity;
+
+ await waitFor(() => expect(button).toBeVisible());
+ expect(discOpacity()).toBe('0');
+
+ await userEvent.click(button);
+
+ await waitFor(() => expect(discOpacity()).toBe('1'));
+};
+
+/** The flow advances to the follow-up, and can be walked back to the scale. */
+export const TheFlowAdvancesAndReturns: StoryFn = (): React.ReactElement => ;
+
+TheFlowAdvancesAndReturns.play = async ({ canvasElement }) => {
+ const { getByRole, queryByRole } = within(canvasElement);
+
+ await userEvent.click(getByRole('radio', { name: 'Love it!' }));
+
+ await waitFor(() =>
+ expect(queryByRole('radiogroup', { name: 'Rate this experience' })).toBeNull(),
+ );
+ const back = getByRole('button', { name: 'Back to rating' });
+
+ await userEvent.click(back);
+
+ await waitFor(() =>
+ expect(getByRole('radio', { name: 'Love it!' })).toHaveAttribute('aria-checked', 'false'),
+ );
+};
diff --git a/packages/blade/src/components/ChatFeedback/ChatFeedback.web.tsx b/packages/blade/src/components/ChatFeedback/ChatFeedback.web.tsx
new file mode 100644
index 0000000000..b6b9ff5bc5
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/ChatFeedback.web.tsx
@@ -0,0 +1,338 @@
+import React from 'react';
+import styled from 'styled-components';
+import type { ChatFeedbackProps, ChatFeedbackStep } from './types';
+import { chatFeedbackChipSize, chatFeedbackSubmitRevealWidth } from './chatFeedbackTokens';
+import { useChatFeedback } from './useChatFeedback';
+import { ChatFeedbackMoodScale } from './ChatFeedbackMoodScale.web';
+import { ChatFeedbackCheck } from './ChatFeedbackCheck.web';
+import { defaultFeedbackIcons } from './moodIcons';
+import BaseBox from '~components/Box/BaseBox';
+import { getStyledProps } from '~components/Box/styledProps';
+import { BaseMotionBox } from '~components/BaseMotion';
+import type { MotionVariantsType } from '~components/BaseMotion';
+import { useTheme } from '~components/BladeProvider';
+import { Button } from '~components/Button';
+import { IconButton } from '~components/Button/IconButton';
+import { Chip, ChipGroup } from '~components/Chip';
+import { CheckIcon, ChevronLeftIcon } from '~components/Icons';
+import { Text } from '~components/Typography';
+import { castWebType, makeSize } from '~utils';
+import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects';
+import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute';
+import { metaAttribute, MetaConstants } from '~utils/metaAttribute';
+import { msToSeconds } from '~utils/msToSeconds';
+import { cssBezierToArray } from '~utils/cssBezierToArray';
+import { useIsMobile } from '~utils/useIsMobile';
+import { chipGroupGapTokens } from '~components/Chip/chipTokens';
+import getIn from '~utils/lodashButBetter/get';
+
+/**
+ * `ChipGroup` always reserves space beneath its chips for the `FormHint` slot, even when no
+ * `helpText` or `errorText` is set. That makes its box taller than the chips themselves, so
+ * centring it in a row leaves the chips sitting above the submit button's centre line.
+ *
+ * Cancelling exactly that reserved space realigns the two. The amount is read from the same
+ * token `ChipGroup` uses, so the two cannot drift apart.
+ */
+const ChipGroupAligner = styled(BaseBox)(({ theme }) => ({
+ marginBottom: `-${getIn(theme, chipGroupGapTokens[chatFeedbackChipSize].bottom)}px`,
+}));
+
+/**
+ * Holds the space its submit control will occupy, before the control exists.
+ *
+ * Ported from the prototype's chip row. The control is revealed on the first tag pick; letting it
+ * arrive in the flow moved every chip 36px left in the same frame, which is the one moment the
+ * user is reading those chips. So the row grows its own right edge on the settle curve and the
+ * control fades into the space that opens, and the chips never move at all.
+ */
+const SubmitReserve = styled(BaseBox)<{ $hasSubmit: boolean }>(({ theme, $hasSubmit }) => ({
+ position: 'relative',
+ paddingRight: $hasSubmit ? makeSize(chatFeedbackSubmitRevealWidth) : '0px',
+ transition: `padding-right ${theme.motion.duration.moderate}ms ${castWebType(
+ theme.motion.easing.settle,
+ )}`,
+}));
+
+/**
+ * The control itself, parked in the space above.
+ *
+ * Absolute so its arrival cannot lay anything out, and `visibility` trails the fade by the fade's
+ * own duration so it leaves the tab order only once it has actually gone — a transparent button
+ * that still takes focus is worse than one that pops.
+ */
+const SubmitSlot = styled.span<{ $isReady: boolean }>(({ theme, $isReady }) => {
+ const fade = theme.motion.duration.moderate;
+ return {
+ position: 'absolute',
+ right: 0,
+ top: '50%',
+ transform: 'translateY(-50%)',
+ display: 'inline-flex',
+ opacity: $isReady ? 1 : 0,
+ visibility: $isReady ? 'visible' : 'hidden',
+ pointerEvents: $isReady ? 'auto' : 'none',
+ transition: `opacity ${fade}ms ${castWebType(theme.motion.easing.entrance)}, visibility 0s ${
+ $isReady ? '0s' : `${fade}ms`
+ }`,
+ };
+});
+
+const _ChatFeedback = ({
+ question = 'How are we doing so far?',
+ moodConfig,
+ onMoodSelect,
+ onTagsChange,
+ controlsRef,
+ isSubmitHidden = false,
+ onSubmit,
+ onDismiss,
+ thanksLabel,
+ autoDismiss = true,
+ isFullWidth = true,
+ isDisabled = false,
+ feedbackIcons = defaultFeedbackIcons,
+ testID,
+ ...rest
+}: ChatFeedbackProps): React.ReactElement => {
+ const { theme } = useTheme();
+ const isMobile = useIsMobile();
+
+ /**
+ * Full-width steps spread edge to edge; hugging steps are only as wide as their content
+ * and rely on the gap to separate the prompt from the controls. This mirrors how the
+ * prototype distinguishes the attached strip from the floating bar — the bar itself does
+ * nothing special, it is the step that stops stretching.
+ */
+ const rowLayout = isFullWidth
+ ? ({ width: '100%', justifyContent: 'space-between', gap: 'spacing.4' } as const)
+ : ({ width: undefined, justifyContent: 'flex-start', gap: 'spacing.3' } as const);
+
+ const {
+ step,
+ selectedMood,
+ selectedTags,
+ question: followUpQuestion,
+ thanksLabel: moodThanksLabel,
+ tags,
+ hasSelectedTags,
+ selectMood,
+ setSelectedTags,
+ submitTags,
+ goBackToMood,
+ } = useChatFeedback({
+ moodConfig,
+ onMoodSelect,
+ onTagsChange,
+ onSubmit,
+ onDismiss,
+ autoDismiss,
+ });
+
+ /*
+ * The handle is published in an effect, and delegates through refs.
+ *
+ * Two constraints pull against each other. It cannot be written during render — that is a side
+ * effect in render, which double-fires under StrictMode and is unsafe once rendering can be
+ * interrupted. But it also cannot be captured once, because `submitTags` closes over the current
+ * selection: a handle built on mount would submit whatever happened to be picked at the time.
+ *
+ * Delegating satisfies both. The published functions are stable and read the latest
+ * implementations out of refs when called, so the handle is written once per `controlsRef`,
+ * never during render, and is never stale. It is cleared on unmount so a host cannot drive a
+ * flow that is no longer on screen.
+ */
+ const latest = React.useRef({ submitTags, setSelectedTags });
+ React.useLayoutEffect(() => {
+ latest.current = { submitTags, setSelectedTags };
+ });
+
+ React.useLayoutEffect(() => {
+ if (!controlsRef) return undefined;
+ controlsRef.current = {
+ submit: () => latest.current.submitTags(),
+ setTags: (values) => latest.current.setSelectedTags(values),
+ };
+
+ return () => {
+ controlsRef.current = null;
+ };
+ }, [controlsRef]);
+
+ // Steps arrive on the settle curve. The exit is a plain fade — the incoming step is the
+ // thing worth watching, so the outgoing one should get out of the way quickly.
+ const stepInVariants: MotionVariantsType = {
+ initial: { opacity: 0, y: -4 },
+ animate: {
+ opacity: 1,
+ y: 0,
+ transition: {
+ duration: msToSeconds(theme.motion.duration.quick),
+ ease: cssBezierToArray(castWebType(theme.motion.easing.settle)),
+ },
+ },
+ exit: {
+ opacity: 0,
+ transition: {
+ duration: msToSeconds(theme.motion.duration['2xquick']),
+ ease: cssBezierToArray(castWebType(theme.motion.easing.exit)),
+ },
+ },
+ };
+
+ // A record rather than a switch, so TypeScript can prove every step renders an element
+ // and the motion wrapper never receives null.
+ const stepRenderers: Record React.ReactElement> = {
+ mood: () => (
+
+
+ {question}
+
+
+
+ ),
+
+ // On mobile the question claims its own line: a prompt, four chips and a submit
+ // cannot share a row below ~400px without overflowing.
+ tags: () => (
+
+ {/* Prompt holds one line (flexShrink 0); when the chips + submit can't fit beside
+ it, the whole group below wraps to the next line rather than the submit
+ orphaning onto a row of its own. */}
+
+
+
+ {followUpQuestion}
+
+
+
+
+
+ setSelectedTags(values)}
+ >
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+ {/*
+ Kept mounted and revealed, rather than mounted on first pick. `SubmitReserve` above has
+ already opened the space it lands in, so revealing it costs the chips nothing — which is
+ what makes it safe to hide in the first place.
+ */}
+
+
+
+
+
+ ),
+
+ thanks: () => (
+
+
+
+
+ {/*
+ An explicit `thanksLabel` speaks for every mood; without one the copy follows the
+ sentiment, so someone who has just said this went wrong is answered with an
+ acknowledgement rather than with delight at their feedback.
+ */}
+ {thanksLabel ?? moodThanksLabel ?? 'Thanks for the feedback!'}
+
+
+
+ ),
+ };
+
+ return (
+
+ {/* Keyed on step so each arrival replays the entrance. */}
+
+ {stepRenderers[step]()}
+
+
+ );
+};
+
+const ChatFeedback = assignWithoutSideEffects(_ChatFeedback, {
+ componentId: MetaConstants.ChatFeedback,
+ displayName: 'ChatFeedback',
+});
+
+export { ChatFeedback };
diff --git a/packages/blade/src/components/ChatFeedback/ChatFeedbackCheck.web.tsx b/packages/blade/src/components/ChatFeedback/ChatFeedbackCheck.web.tsx
new file mode 100644
index 0000000000..e8489f36a7
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/ChatFeedbackCheck.web.tsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import styled, { keyframes } from 'styled-components';
+import BaseBox from '~components/Box/BaseBox';
+import { CheckIcon } from '~components/Icons';
+import { castWebType } from '~utils';
+
+/**
+ * The mark the thank-you step ends on: a filled positive disc carrying a white check.
+ *
+ * ## Why a check and not a face or a star
+ *
+ * The step it closes is a rating — four faces the user just chose between. Answering that with a
+ * fifth piece of expressive artwork puts them back in front of the control they have finished
+ * with, and a celebratory illustration reads as the start of something rather than the end of it.
+ * A check says the opposite: this is settled, nothing further is being asked.
+ *
+ * It also replaces a hand-authored gradient asset that was never a Blade icon — two ink colours
+ * and a radial gradient, neither of which the single-colour icon contract supports, and a
+ * `React.useId` for the gradient id that made the whole component unusable on React 17. Composing
+ * a token-coloured disc around Blade's own `CheckIcon` costs none of that.
+ *
+ * ## The two sizes
+ *
+ * Blade ships `CheckCircleIcon`, but that is the outline form — a ring with a tick in it, which at
+ * this size reads as one more control rather than as a result. The filled disc is the final one.
+ *
+ * 16px sits at about the cap height of the line beside it, so the mark reads as punctuation on
+ * that sentence rather than competing with it; an earlier 20px disc was the loudest thing on a
+ * strip whose whole job at that moment is to leave. The check is sized independently — Blade's
+ * icon scale is xsmall 8, small 12, medium 16 — so `small` fills the disc to a 2px ring. At
+ * `xsmall` the 8px tick floated in a green field and read as a dot: it is the ring, not the disc,
+ * that makes it read as a check.
+ */
+const DISC_SIZE = { small: '16px', medium: '40px' } as const;
+const ICON_SIZE = { small: 'small', medium: 'medium' } as const;
+
+type ChatFeedbackCheckProps = {
+ /** `small` sits inline beside one line of text; `medium` suits a panel of its own. */
+ size?: keyof typeof DISC_SIZE;
+};
+
+/**
+ * The one place on this strip where a little delight is affordable.
+ *
+ * Everything else here is seen on the way to something — the faces are a control, the chips are a
+ * control, and motion on either would be motion in the user's way. This mark is terminal: it is
+ * shown once, at the end, and the flow leaves a moment later. That is the whole delight budget,
+ * so it is spent here and nowhere else.
+ *
+ * It starts at 0.9 rather than 0. Nothing in the world appears out of nothing, and a disc
+ * inflating from a point reads as a spinner starting rather than as an answer landing. The
+ * `overshoot` curve carries it a hair past its size and back, which is what makes it feel
+ * stamped rather than faded in — small enough at a 0.1 delta to stay a confirmation, not a
+ * celebration.
+ */
+const pop = keyframes`
+ from {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+`;
+
+const Disc = styled(BaseBox)`
+ animation: ${pop} ${({ theme }) => theme.motion.duration.quick}ms
+ ${({ theme }) => castWebType(theme.motion.easing.overshoot)} both;
+
+ /* The fade still says "this is new"; the scale is the part that only decorates. */
+ @media (prefers-reduced-motion: reduce) {
+ animation-name: none;
+ opacity: 1;
+ }
+`;
+
+const ChatFeedbackCheck = ({ size = 'small' }: ChatFeedbackCheckProps): React.ReactElement => (
+
+
+
+);
+
+export { ChatFeedbackCheck };
+export type { ChatFeedbackCheckProps };
diff --git a/packages/blade/src/components/ChatFeedback/ChatFeedbackMoodScale.web.tsx b/packages/blade/src/components/ChatFeedback/ChatFeedbackMoodScale.web.tsx
new file mode 100644
index 0000000000..d455f9e64a
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/ChatFeedbackMoodScale.web.tsx
@@ -0,0 +1,258 @@
+import React from 'react';
+import styled, { keyframes } from 'styled-components';
+import type { ChatFeedbackMood, ChatFeedbackIcons } from './types';
+import {
+ chatFeedbackMoods,
+ chatFeedbackMoodTokens,
+ chatFeedbackMoodGlyphSize,
+ chatFeedbackMoodButtonSize,
+ chatFeedbackMoodDiscSize,
+} from './chatFeedbackTokens';
+import BaseBox from '~components/Box/BaseBox';
+import { useTheme } from '~components/BladeProvider';
+import { Tooltip } from '~components/Tooltip';
+import { castWebType, makeSpace, makeSize } from '~utils';
+import getIn from '~utils/lodashButBetter/get';
+
+/** Gap between one face arriving and the next. Short enough that the row still reads as one beat. */
+const MOOD_STAGGER_MS = 30;
+
+const MoodButton = styled.button<{
+ $activeColor: string;
+ $activeSurface: string;
+ $isSelected: boolean;
+}>(({ theme, $activeColor, $activeSurface, $isSelected }) => {
+ /*
+ * Hover, keyboard focus and selection all resolve to one treatment: the mood's colour behind
+ * the button, the mood's colour on the glyph, a slight lift, and — where Blade owns the artwork
+ * — the filled face swapped in for the outline.
+ *
+ * The background is what makes this survive `feedbackIcons`. Every other cue acts on the glyph, and
+ * a consumer-supplied one may be untintable and has no filled twin, so those cues quietly do
+ * nothing; a 12% scale on its own is not enough to tell someone their rating registered.
+ * Colouring the button reads the same whatever is drawn on top of it.
+ */
+ /*
+ * The disc is drawn by a pseudo-element, not by the button's own background.
+ *
+ * The button is deliberately larger than the disc: it carries the tap target, which has to stay
+ * at 44px, while the disc is a visual and reads better a little tighter around the glyph. Tying
+ * the two together would mean trading one for the other — shrinking the target to shrink the
+ * circle is the swap that put the adjacent-rating mis-taps back.
+ */
+ const activeState = {
+ color: $activeColor,
+ transform: 'scale(1.12)',
+ '&::before': { opacity: 1 },
+ };
+
+ return {
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ /*
+ * A 44px box around a 28px glyph, butted against its neighbours, so the visible gap between
+ * faces is the 16px of padding two adjacent buttons contribute.
+ *
+ * That padding is what keeps the row safe to aim at. An earlier 24px glyph left only 4px, so
+ * a tap a couple of pixels wide of a face landed on its neighbour and silently recorded the
+ * *adjacent* rating — on a four-point scale, "satisfied" becoming "dissatisfied" is a wrong
+ * answer rather than a near miss. The padding is held at 8px as the glyph grows, which is why
+ * the box grew with it rather than the faces being packed tighter.
+ *
+ * At 44px the target now meets the touch-target guidance it used to sit under. The row is
+ * correspondingly taller, which is the cost of faces a merchant can actually read.
+ */
+ minWidth: makeSize(chatFeedbackMoodButtonSize),
+ minHeight: makeSize(chatFeedbackMoodButtonSize),
+ padding: makeSpace(theme.spacing[3]),
+ border: 'none',
+ backgroundColor: 'transparent',
+ // Centred behind the glyph, revealed on hover, focus and selection.
+ position: 'relative',
+ '&::before': {
+ content: '""',
+ position: 'absolute',
+ width: makeSize(chatFeedbackMoodDiscSize),
+ height: makeSize(chatFeedbackMoodDiscSize),
+ borderRadius: makeSpace(theme.border.radius.max),
+ backgroundColor: $activeSurface,
+ opacity: 0,
+ transition: `opacity ${theme.motion.duration.xquick}ms ${castWebType(
+ theme.motion.easing.settle,
+ )}`,
+ },
+ // The glyph sits above the disc.
+ '& > *': { position: 'relative' },
+ // Circular, so the surface reads as a halo around the glyph rather than as a chip.
+ borderRadius: makeSpace(theme.border.radius.max),
+ cursor: 'pointer',
+ // Icons inherit this via `color="currentColor"`, so one declaration tints both layers.
+ // Kept light at rest so the scale reads as an invitation rather than a set of filled
+ // controls — legibility comes from the face shapes, not from stroke weight.
+ color: getIn(theme.colors, 'surface.icon.gray.muted'),
+ transform: 'scale(1)',
+ transition: `transform ${theme.motion.duration.xquick}ms ${castWebType(
+ theme.motion.easing.settle,
+ )}, color ${theme.motion.duration.xquick}ms ${castWebType(
+ theme.motion.easing.settle,
+ )}, background-color ${theme.motion.duration.xquick}ms ${castWebType(
+ theme.motion.easing.settle,
+ )}`,
+
+ '&:hover:not(:disabled)': activeState,
+ '&:focus-visible': { ...activeState, outline: 'none' },
+ ...($isSelected ? activeState : {}),
+
+ /*
+ * The press.
+ *
+ * Every other state on this button scales *up*, so without this a press had nowhere to go —
+ * the face was already at 1.12 from the hover that necessarily preceded it, and clicking
+ * changed nothing until the answer had been recorded. A dip back toward rest is the only
+ * movement available, and it is the one that reads as a press.
+ *
+ * Deliberately last, so it wins over the hover, focus and selected rules above it, and
+ * deliberately quicker than them: the user is waiting on this one, where the others merely
+ * follow a pointer.
+ */
+ '&:active:not(:disabled)': {
+ ...activeState,
+ transform: 'scale(1.06)',
+ transitionDuration: `${theme.motion.duration['2xquick']}ms`,
+ },
+
+ '&:disabled': {
+ cursor: 'not-allowed',
+ color: getIn(theme.colors, 'surface.icon.gray.disabled'),
+ },
+ };
+});
+
+type ChatFeedbackMoodScaleProps = {
+ selectedMood: ChatFeedbackMood | null;
+ isDisabled?: boolean;
+ onSelect: (mood: ChatFeedbackMood) => void;
+ feedbackIcons: ChatFeedbackIcons;
+};
+
+/**
+ * Each face arrives a beat after the one before it.
+ *
+ * The strip already animates in, and the step inside it animates too, so this is a third layer
+ * and had to earn its place. It does, because the four faces are the only part a merchant has to
+ * choose between: arriving in sequence reads as a row being dealt, where arriving together reads
+ * as a block appearing. 30ms is deliberately at the short end — the last face is settled inside
+ * the beat the step itself occupies, so nothing waits on it.
+ */
+const enter = keyframes`
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+`;
+
+/**
+ * Holds whatever a consumer supplies to the size Blade's own glyphs occupy.
+ *
+ * Without a box of its own, one oversized asset stretches the button, and the row's pitch goes
+ * with it. Anything inside is hidden from assistive tech: the button already carries the mood's
+ * name, and a decorative face announcing itself a second time is noise.
+ *
+ * The entrance lives here rather than on the button because the button's transform belongs to
+ * hover — an animation there would hold its final keyframe and the hover would never move again.
+ */
+const MoodIconSlot = styled.span<{ $index: number }>`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ /*
+ * 28px, off the spacing scale, which stops at 24 and jumps to 32.
+ *
+ * A face is not an icon: an icon is one shape a merchant reads at a glance, while these carry
+ * eyes, a mouth and — on two of them — a thumb, and every one of those has to survive the same
+ * pass. At the 20px this slot used to be, the artwork inside its own box left the faces around
+ * 15px and the tear a couple of pixels wide; four of them side by side read as coloured dots
+ * rather than as expressions.
+ */
+ width: ${makeSize(chatFeedbackMoodGlyphSize)};
+ height: ${makeSize(chatFeedbackMoodGlyphSize)};
+ font-size: ${makeSize(chatFeedbackMoodGlyphSize)};
+ line-height: 1;
+ animation: ${enter} ${({ theme }) => theme.motion.duration.xquick}ms
+ ${({ theme }) => castWebType(theme.motion.easing.entrance)}
+ ${({ $index }) => $index * MOOD_STAGGER_MS}ms both;
+
+ & > * {
+ max-width: 100%;
+ max-height: 100%;
+ }
+
+ /* Reduced motion keeps the fade, which explains the arrival, and drops the travel, which does not. */
+ @media (prefers-reduced-motion: reduce) {
+ animation-name: none;
+ opacity: 1;
+ }
+`;
+
+const ChatFeedbackMoodScale = ({
+ selectedMood,
+ isDisabled,
+ onSelect,
+ feedbackIcons,
+}: ChatFeedbackMoodScaleProps): React.ReactElement => {
+ const { theme } = useTheme();
+
+ return (
+
+ {chatFeedbackMoods.map((mood, index) => {
+ const { color, surfaceColor, label } = chatFeedbackMoodTokens[mood];
+ const isSelected = selectedMood === mood;
+
+ return (
+ /*
+ * The label is already the button's accessible name; the tooltip puts the same words
+ * on screen. Four similar glyphs at 20px is exactly where a guess goes wrong, and here
+ * a wrong guess records the wrong rating rather than merely costing a click.
+ */
+
+ onSelect(mood)}
+ >
+ {/*
+ Rendered as given rather than adapted: what arrives may be an SVG, an emoji
+ character or an image, and no single contract would let Blade tint or resize all
+ three. The slot only bounds its size, and the button carries the selected state —
+ which is why that state had to move off the glyph.
+ */}
+
+ {feedbackIcons?.[mood]}
+
+
+
+ );
+ })}
+
+ );
+};
+
+export { ChatFeedbackMoodScale };
diff --git a/packages/blade/src/components/ChatFeedback/_KitchenSink.ChatFeedback.stories.tsx b/packages/blade/src/components/ChatFeedback/_KitchenSink.ChatFeedback.stories.tsx
new file mode 100644
index 0000000000..c5c0804769
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/_KitchenSink.ChatFeedback.stories.tsx
@@ -0,0 +1,31 @@
+import { composeStories } from '@storybook/react-vite';
+import * as chatFeedbackStories from './docs/ChatFeedback.stories';
+import { Box } from '~components/Box';
+import { Heading } from '~components/Typography';
+
+const allStories = Object.values(composeStories(chatFeedbackStories));
+
+export const ChatFeedback = (): JSX.Element => {
+ return (
+
+ {allStories.map((Story) => {
+ return (
+ <>
+ {Story.storyName}
+
+ >
+ );
+ })}
+
+ );
+};
+
+export default {
+ title: 'Components/KitchenSink/ChatFeedback',
+ component: ChatFeedback,
+ parameters: {
+ // enable Chromatic's snapshotting only for kitchensink
+ chromatic: { disableSnapshot: false },
+ options: { showPanel: false },
+ },
+};
diff --git a/packages/blade/src/components/ChatFeedback/__tests__/ChatFeedback.web.test.tsx b/packages/blade/src/components/ChatFeedback/__tests__/ChatFeedback.web.test.tsx
new file mode 100644
index 0000000000..55a2bae4ba
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/__tests__/ChatFeedback.web.test.tsx
@@ -0,0 +1,260 @@
+import userEvent from '@testing-library/user-event';
+import { act, waitFor } from '@testing-library/react';
+import { ChatFeedback } from '../ChatFeedback';
+import type { ChatFeedbackControls } from '../types';
+import renderWithTheme from '~utils/testing/renderWithTheme.web';
+import assertAccessible from '~utils/testing/assertAccessible.web';
+
+describe('', () => {
+ it('should render the mood step by default', () => {
+ const { container } = renderWithTheme();
+ expect(container).toMatchSnapshot();
+ });
+
+ it('should render the question and all four moods', () => {
+ const { getByText, getByLabelText } = renderWithTheme(
+ ,
+ );
+
+ expect(getByText('How did that go?')).toBeInTheDocument();
+ expect(getByLabelText('Terrible')).toBeInTheDocument();
+ expect(getByLabelText('Bad')).toBeInTheDocument();
+ expect(getByLabelText('Good')).toBeInTheDocument();
+ expect(getByLabelText('Love it!')).toBeInTheDocument();
+ });
+
+ it('should fire onMoodSelect and move to the tags step', async () => {
+ const onMoodSelect = jest.fn();
+ const { getByLabelText, findByText } = renderWithTheme(
+ ,
+ );
+
+ await userEvent.click(getByLabelText('Love it!'));
+
+ expect(onMoodSelect).toHaveBeenCalledWith({ mood: 'very-satisfied' });
+ expect(await findByText('Love it! What stood out most?')).toBeInTheDocument();
+ });
+
+ it('should mark the picked mood as checked', async () => {
+ const { getByLabelText } = renderWithTheme();
+ const satisfied = getByLabelText('Good');
+
+ expect(satisfied).toHaveAttribute('aria-checked', 'false');
+ await userEvent.click(satisfied);
+ expect(satisfied).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('should keep submit disabled until at least one tag is picked', async () => {
+ const { getByLabelText, findByRole, getByText } = renderWithTheme();
+
+ await userEvent.click(getByLabelText('Good'));
+ const submit = await findByRole('button', { name: 'Submit feedback' });
+ expect(submit).toBeDisabled();
+
+ await userEvent.click(getByText('Helpful'));
+ expect(submit).toBeEnabled();
+ });
+
+ it('should submit the mood and selected tags', async () => {
+ const onSubmit = jest.fn();
+ const { getByLabelText, findByRole, findByText, getByText } = renderWithTheme(
+ ,
+ );
+
+ await userEvent.click(getByLabelText('Good'));
+ await userEvent.click(await findByText('Helpful'));
+ await userEvent.click(getByText('Clear'));
+ await userEvent.click(await findByRole('button', { name: 'Submit feedback' }));
+
+ expect(onSubmit).toHaveBeenCalledWith({ mood: 'satisfied', tags: ['Helpful', 'Clear'] });
+ });
+
+ it('should show the thanks step after submitting', async () => {
+ const { getByLabelText, findByRole, findByText } = renderWithTheme(
+ ,
+ );
+
+ await userEvent.click(getByLabelText('Good'));
+ await userEvent.click(await findByText('Helpful'));
+ await userEvent.click(await findByRole('button', { name: 'Submit feedback' }));
+
+ expect(await findByText('Thanks for the feedback!')).toBeInTheDocument();
+ });
+
+ it('should clear the selection when going back to the mood step', async () => {
+ const { getByLabelText, findByRole } = renderWithTheme();
+
+ await userEvent.click(getByLabelText('Good'));
+ await userEvent.click(await findByRole('button', { name: 'Back to rating' }));
+
+ await waitFor(() => {
+ expect(getByLabelText('Good')).toHaveAttribute('aria-checked', 'false');
+ });
+ });
+
+ it('should not offer a free-text follow-up of its own', async () => {
+ const { getByLabelText, findByRole, findByText, queryByRole } = renderWithTheme(
+ ,
+ );
+
+ await userEvent.click(getByLabelText('Good'));
+ await userEvent.click(await findByText('Helpful'));
+ await userEvent.click(await findByRole('button', { name: 'Submit feedback' }));
+ await findByText('Thanks for the feedback!');
+
+ // Free text is the surrounding surface's job now — a composer, typically. This component
+ // ends on the confirmation rather than opening a second act.
+ expect(queryByRole('textbox')).toBeNull();
+ });
+
+ it('should call onDismiss after the thanks step when autoDismiss is on', async () => {
+ const onDismiss = jest.fn();
+ const { getByLabelText, findByRole, findByText } = renderWithTheme(
+ ,
+ );
+
+ await userEvent.click(getByLabelText('Good'));
+ await userEvent.click(await findByText('Helpful'));
+ await userEvent.click(await findByRole('button', { name: 'Submit feedback' }));
+
+ await waitFor(() => expect(onDismiss).toHaveBeenCalledTimes(1), { timeout: 4000 });
+ });
+
+ // Synchronous by design: fake timers drive the whole assertion, so there is nothing to await.
+ it('should not call onDismiss when autoDismiss is off', () => {
+ jest.useFakeTimers();
+ const onDismiss = jest.fn();
+ renderWithTheme();
+
+ jest.advanceTimersByTime(5000);
+ expect(onDismiss).not.toHaveBeenCalled();
+ jest.useRealTimers();
+ });
+
+ it('should honour a custom moodConfig', async () => {
+ const { getByLabelText, findByText } = renderWithTheme(
+ ,
+ );
+
+ await userEvent.click(getByLabelText('Good'));
+
+ expect(await findByText('Nice! Why?')).toBeInTheDocument();
+ expect(await findByText('Speedy')).toBeInTheDocument();
+ });
+
+ it('should disable every control when isDisabled is true', () => {
+ const { getByLabelText } = renderWithTheme();
+ expect(getByLabelText('Good')).toBeDisabled();
+ });
+
+ it('should not have accessibility violations', async () => {
+ const { container } = renderWithTheme();
+ await assertAccessible(container);
+ });
+
+ it('should not have accessibility violations on the follow-up step', async () => {
+ const { container, getByLabelText, findByRole } = renderWithTheme();
+
+ await userEvent.click(getByLabelText('Good'));
+ await findByRole('button', { name: 'Back to rating' });
+
+ await assertAccessible(container);
+ });
+
+ it('should expose the scale as a radio group with a name for every point', () => {
+ const { getByRole } = renderWithTheme();
+
+ expect(getByRole('radiogroup', { name: 'Rate this experience' })).toBeTruthy();
+ ['Terrible', 'Bad', 'Good', 'Love it!'].forEach((label) => {
+ expect(getByRole('radio', { name: label })).toBeTruthy();
+ });
+ });
+
+ describe('feedbackIcons', () => {
+ /*
+ * Emoji rather than the shipped SVGs. Untintable artwork with no filled twin is the case worth
+ * proving: it is where the selected state has to survive on the button alone.
+ */
+ const emojiIcons = {
+ 'very-dissatisfied': 😢,
+ dissatisfied: 😕,
+ satisfied: 🙂,
+ 'very-satisfied': 😍,
+ };
+
+ it('should render the supplied artwork for every mood', () => {
+ const { getByRole } = renderWithTheme();
+
+ expect(getByRole('radio', { name: 'Good' })).toHaveTextContent('🙂');
+ expect(getByRole('radio', { name: 'Terrible' })).toHaveTextContent('😢');
+ });
+
+ it('should keep supplied artwork out of the accessibility tree', () => {
+ const { getByRole } = renderWithTheme();
+ const button = getByRole('radio', { name: 'Good' });
+
+ // The button already carries the mood's name; the glyph naming itself again is noise.
+ expect(button.querySelector('[aria-hidden="true"]')).not.toBeNull();
+ });
+
+ /*
+ * The selected treatment is a tinted disc drawn by a pseudo-element, which jsdom cannot
+ * compute — `getComputedStyle(el, '::before')` returns nothing useful here. The visual is
+ * asserted in `ChatFeedback.test.stories.tsx`, which runs in a real browser; what this level
+ * can prove is that selection is recorded at all with artwork that carries none of it.
+ */
+ it('should record the selection with untintable artwork', async () => {
+ const { getByRole } = renderWithTheme();
+ const button = getByRole('radio', { name: 'Good' });
+
+ expect(button).toHaveAttribute('aria-checked', 'false');
+
+ await userEvent.click(button);
+
+ expect(button).toHaveAttribute('aria-checked', 'true');
+ });
+ });
+
+ describe('controlsRef', () => {
+ /*
+ * The handle has to reflect the selection at the moment it is called, not at mount. It is
+ * published from an effect rather than during render, so it delegates through refs — this is
+ * the test that the delegation actually keeps it current.
+ */
+ it('should submit the selection as it stands when the handle is called', async () => {
+ const onSubmit = jest.fn();
+ const controlsRef: { current: ChatFeedbackControls | null } = { current: null };
+ const { getByLabelText, findByText } = renderWithTheme(
+ ,
+ );
+
+ await userEvent.click(getByLabelText('Good'));
+ await userEvent.click(await findByText('Helpful'));
+
+ controlsRef.current?.submit();
+
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ mood: 'satisfied', tags: ['Helpful'] }),
+ );
+ });
+
+ it('should let a host replace the selection', async () => {
+ const controlsRef: { current: ChatFeedbackControls | null } = { current: null };
+ const { getByLabelText, findByRole } = renderWithTheme(
+ ,
+ );
+
+ await userEvent.click(getByLabelText('Good'));
+ await findByRole('checkbox', { name: 'Helpful' });
+
+ // Synchronous state update, wrapped so React flushes it before the assertion.
+ act(() => {
+ controlsRef.current?.setTags(['Helpful']);
+ });
+
+ await waitFor(() => expect(getByLabelText('Helpful')).toBeChecked());
+ });
+ });
+});
diff --git a/packages/blade/src/components/ChatFeedback/__tests__/__snapshots__/ChatFeedback.web.test.tsx.snap b/packages/blade/src/components/ChatFeedback/__tests__/__snapshots__/ChatFeedback.web.test.tsx.snap
new file mode 100644
index 0000000000..98a0103b79
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/__tests__/__snapshots__/ChatFeedback.web.test.tsx.snap
@@ -0,0 +1,959 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` should render the mood step by default 1`] = `
+.c0.c0.c0.c0.c0 {
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-flex-direction: column;
+ -ms-flex-direction: column;
+ flex-direction: column;
+ -webkit-align-items: stretch;
+ -webkit-box-align: stretch;
+ -ms-flex-align: stretch;
+ align-items: stretch;
+ width: 100%;
+}
+
+.c1.c1.c1.c1.c1 {
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-flex-direction: row;
+ -ms-flex-direction: row;
+ flex-direction: row;
+ -webkit-align-items: center;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: justify;
+ -webkit-justify-content: space-between;
+ -ms-flex-pack: justify;
+ justify-content: space-between;
+ width: 100%;
+ gap: 12px;
+}
+
+.c3.c3.c3.c3.c3 {
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-flex-direction: row;
+ -ms-flex-direction: row;
+ flex-direction: row;
+ -webkit-flex-shrink: 0;
+ -ms-flex-negative: 0;
+ flex-shrink: 0;
+ -webkit-align-items: center;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+}
+
+.c2.c2.c2.c2.c2 {
+ color: hsla(200,10%,18%,1);
+ font-family: "Inter","Inter Fallback Arial",Arial;
+ font-size: 0.875rem;
+ font-weight: 500;
+ font-style: normal;
+ -webkit-text-decoration-line: none;
+ text-decoration-line: none;
+ line-height: 1.25rem;
+ -webkit-letter-spacing: -0.18200000000000002px;
+ -moz-letter-spacing: -0.18200000000000002px;
+ -ms-letter-spacing: -0.18200000000000002px;
+ letter-spacing: -0.18200000000000002px;
+ margin: 0;
+ padding: 0;
+}
+
+.c4.c4.c4.c4.c4 {
+ display: -webkit-inline-box;
+ display: -webkit-inline-flex;
+ display: -ms-inline-flexbox;
+ display: inline-flex;
+ -webkit-align-items: center;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: center;
+ -webkit-justify-content: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ min-width: 44px;
+ min-height: 44px;
+ padding: 8px;
+ border: none;
+ background-color: transparent;
+ position: relative;
+ border-radius: 9999px;
+ cursor: pointer;
+ color: hsla(204,9%,42%,1);
+ -webkit-transform: scale(1);
+ -ms-transform: scale(1);
+ transform: scale(1);
+ -webkit-transition: -webkit-transform 160ms cubic-bezier(0.32,0.72,0,1),color 160ms cubic-bezier(0.32,0.72,0,1),background-color 160ms cubic-bezier(0.32,0.72,0,1);
+ -webkit-transition: transform 160ms cubic-bezier(0.32,0.72,0,1),color 160ms cubic-bezier(0.32,0.72,0,1),background-color 160ms cubic-bezier(0.32,0.72,0,1);
+ transition: transform 160ms cubic-bezier(0.32,0.72,0,1),color 160ms cubic-bezier(0.32,0.72,0,1),background-color 160ms cubic-bezier(0.32,0.72,0,1);
+}
+
+.c4.c4.c4.c4.c4::before {
+ content: "";
+ position: absolute;
+ width: 36px;
+ height: 36px;
+ border-radius: 9999px;
+ background-color: hsla(4,85%,44%,0.09);
+ opacity: 0;
+ -webkit-transition: opacity 160ms cubic-bezier(0.32,0.72,0,1);
+ transition: opacity 160ms cubic-bezier(0.32,0.72,0,1);
+}
+
+.c4.c4.c4.c4.c4 > * {
+ position: relative;
+}
+
+.c4.c4.c4.c4.c4:hover:not(:disabled) {
+ color: hsla(4,85%,44%,1);
+ -webkit-transform: scale(1.12);
+ -ms-transform: scale(1.12);
+ transform: scale(1.12);
+}
+
+.c4.c4.c4.c4.c4:hover:not(:disabled)::before {
+ opacity: 1;
+}
+
+.c4.c4.c4.c4.c4:focus-visible {
+ color: hsla(4,85%,44%,1);
+ -webkit-transform: scale(1.12);
+ -ms-transform: scale(1.12);
+ transform: scale(1.12);
+ outline: none;
+}
+
+.c4.c4.c4.c4.c4:focus-visible::before {
+ opacity: 1;
+}
+
+.c4.c4.c4.c4.c4:active:not(:disabled) {
+ color: hsla(4,85%,44%,1);
+ -webkit-transform: scale(1.06);
+ -ms-transform: scale(1.06);
+ transform: scale(1.06);
+ -webkit-transition-duration: 80ms;
+ transition-duration: 80ms;
+}
+
+.c4.c4.c4.c4.c4:active:not(:disabled)::before {
+ opacity: 1;
+}
+
+.c4.c4.c4.c4.c4:disabled {
+ cursor: not-allowed;
+ color: hsla(206,10%,29%,0.32);
+}
+
+.c7.c7.c7.c7.c7 {
+ display: -webkit-inline-box;
+ display: -webkit-inline-flex;
+ display: -ms-inline-flexbox;
+ display: inline-flex;
+ -webkit-align-items: center;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: center;
+ -webkit-justify-content: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ min-width: 44px;
+ min-height: 44px;
+ padding: 8px;
+ border: none;
+ background-color: transparent;
+ position: relative;
+ border-radius: 9999px;
+ cursor: pointer;
+ color: hsla(204,9%,42%,1);
+ -webkit-transform: scale(1);
+ -ms-transform: scale(1);
+ transform: scale(1);
+ -webkit-transition: -webkit-transform 160ms cubic-bezier(0.32,0.72,0,1),color 160ms cubic-bezier(0.32,0.72,0,1),background-color 160ms cubic-bezier(0.32,0.72,0,1);
+ -webkit-transition: transform 160ms cubic-bezier(0.32,0.72,0,1),color 160ms cubic-bezier(0.32,0.72,0,1),background-color 160ms cubic-bezier(0.32,0.72,0,1);
+ transition: transform 160ms cubic-bezier(0.32,0.72,0,1),color 160ms cubic-bezier(0.32,0.72,0,1),background-color 160ms cubic-bezier(0.32,0.72,0,1);
+}
+
+.c7.c7.c7.c7.c7::before {
+ content: "";
+ position: absolute;
+ width: 36px;
+ height: 36px;
+ border-radius: 9999px;
+ background-color: hsla(25,100%,44%,0.09);
+ opacity: 0;
+ -webkit-transition: opacity 160ms cubic-bezier(0.32,0.72,0,1);
+ transition: opacity 160ms cubic-bezier(0.32,0.72,0,1);
+}
+
+.c7.c7.c7.c7.c7 > * {
+ position: relative;
+}
+
+.c7.c7.c7.c7.c7:hover:not(:disabled) {
+ color: hsla(25,100%,39%,1);
+ -webkit-transform: scale(1.12);
+ -ms-transform: scale(1.12);
+ transform: scale(1.12);
+}
+
+.c7.c7.c7.c7.c7:hover:not(:disabled)::before {
+ opacity: 1;
+}
+
+.c7.c7.c7.c7.c7:focus-visible {
+ color: hsla(25,100%,39%,1);
+ -webkit-transform: scale(1.12);
+ -ms-transform: scale(1.12);
+ transform: scale(1.12);
+ outline: none;
+}
+
+.c7.c7.c7.c7.c7:focus-visible::before {
+ opacity: 1;
+}
+
+.c7.c7.c7.c7.c7:active:not(:disabled) {
+ color: hsla(25,100%,39%,1);
+ -webkit-transform: scale(1.06);
+ -ms-transform: scale(1.06);
+ transform: scale(1.06);
+ -webkit-transition-duration: 80ms;
+ transition-duration: 80ms;
+}
+
+.c7.c7.c7.c7.c7:active:not(:disabled)::before {
+ opacity: 1;
+}
+
+.c7.c7.c7.c7.c7:disabled {
+ cursor: not-allowed;
+ color: hsla(206,10%,29%,0.32);
+}
+
+.c10.c10.c10.c10.c10 {
+ display: -webkit-inline-box;
+ display: -webkit-inline-flex;
+ display: -ms-inline-flexbox;
+ display: inline-flex;
+ -webkit-align-items: center;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: center;
+ -webkit-justify-content: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ min-width: 44px;
+ min-height: 44px;
+ padding: 8px;
+ border: none;
+ background-color: transparent;
+ position: relative;
+ border-radius: 9999px;
+ cursor: pointer;
+ color: hsla(204,9%,42%,1);
+ -webkit-transform: scale(1);
+ -ms-transform: scale(1);
+ transform: scale(1);
+ -webkit-transition: -webkit-transform 160ms cubic-bezier(0.32,0.72,0,1),color 160ms cubic-bezier(0.32,0.72,0,1),background-color 160ms cubic-bezier(0.32,0.72,0,1);
+ -webkit-transition: transform 160ms cubic-bezier(0.32,0.72,0,1),color 160ms cubic-bezier(0.32,0.72,0,1),background-color 160ms cubic-bezier(0.32,0.72,0,1);
+ transition: transform 160ms cubic-bezier(0.32,0.72,0,1),color 160ms cubic-bezier(0.32,0.72,0,1),background-color 160ms cubic-bezier(0.32,0.72,0,1);
+}
+
+.c10.c10.c10.c10.c10::before {
+ content: "";
+ position: absolute;
+ width: 36px;
+ height: 36px;
+ border-radius: 9999px;
+ background-color: hsla(150,100%,28%,0.09);
+ opacity: 0;
+ -webkit-transition: opacity 160ms cubic-bezier(0.32,0.72,0,1);
+ transition: opacity 160ms cubic-bezier(0.32,0.72,0,1);
+}
+
+.c10.c10.c10.c10.c10 > * {
+ position: relative;
+}
+
+.c10.c10.c10.c10.c10:hover:not(:disabled) {
+ color: hsla(150,100%,23%,1);
+ -webkit-transform: scale(1.12);
+ -ms-transform: scale(1.12);
+ transform: scale(1.12);
+}
+
+.c10.c10.c10.c10.c10:hover:not(:disabled)::before {
+ opacity: 1;
+}
+
+.c10.c10.c10.c10.c10:focus-visible {
+ color: hsla(150,100%,23%,1);
+ -webkit-transform: scale(1.12);
+ -ms-transform: scale(1.12);
+ transform: scale(1.12);
+ outline: none;
+}
+
+.c10.c10.c10.c10.c10:focus-visible::before {
+ opacity: 1;
+}
+
+.c10.c10.c10.c10.c10:active:not(:disabled) {
+ color: hsla(150,100%,23%,1);
+ -webkit-transform: scale(1.06);
+ -ms-transform: scale(1.06);
+ transform: scale(1.06);
+ -webkit-transition-duration: 80ms;
+ transition-duration: 80ms;
+}
+
+.c10.c10.c10.c10.c10:active:not(:disabled)::before {
+ opacity: 1;
+}
+
+.c10.c10.c10.c10.c10:disabled {
+ cursor: not-allowed;
+ color: hsla(206,10%,29%,0.32);
+}
+
+.c5.c5.c5.c5.c5 {
+ display: -webkit-inline-box;
+ display: -webkit-inline-flex;
+ display: -ms-inline-flexbox;
+ display: inline-flex;
+ -webkit-align-items: center;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: center;
+ -webkit-justify-content: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ font-size: 28px;
+ line-height: 1;
+ -webkit-animation: dISXmL-450290765 160ms cubic-bezier(0,0,0.2,1) 0ms both;
+ animation: dISXmL-450290765 160ms cubic-bezier(0,0,0.2,1) 0ms both;
+}
+
+.c5.c5.c5.c5.c5 > * {
+ max-width: 100%;
+ max-height: 100%;
+}
+
+.c8.c8.c8.c8.c8 {
+ display: -webkit-inline-box;
+ display: -webkit-inline-flex;
+ display: -ms-inline-flexbox;
+ display: inline-flex;
+ -webkit-align-items: center;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: center;
+ -webkit-justify-content: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ font-size: 28px;
+ line-height: 1;
+ -webkit-animation: dISXmL-450290765 160ms cubic-bezier(0,0,0.2,1) 30ms both;
+ animation: dISXmL-450290765 160ms cubic-bezier(0,0,0.2,1) 30ms both;
+}
+
+.c8.c8.c8.c8.c8 > * {
+ max-width: 100%;
+ max-height: 100%;
+}
+
+.c11.c11.c11.c11.c11 {
+ display: -webkit-inline-box;
+ display: -webkit-inline-flex;
+ display: -ms-inline-flexbox;
+ display: inline-flex;
+ -webkit-align-items: center;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: center;
+ -webkit-justify-content: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ font-size: 28px;
+ line-height: 1;
+ -webkit-animation: dISXmL-450290765 160ms cubic-bezier(0,0,0.2,1) 60ms both;
+ animation: dISXmL-450290765 160ms cubic-bezier(0,0,0.2,1) 60ms both;
+}
+
+.c11.c11.c11.c11.c11 > * {
+ max-width: 100%;
+ max-height: 100%;
+}
+
+.c13.c13.c13.c13.c13 {
+ display: -webkit-inline-box;
+ display: -webkit-inline-flex;
+ display: -ms-inline-flexbox;
+ display: inline-flex;
+ -webkit-align-items: center;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: center;
+ -webkit-justify-content: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ font-size: 28px;
+ line-height: 1;
+ -webkit-animation: dISXmL-450290765 160ms cubic-bezier(0,0,0.2,1) 90ms both;
+ animation: dISXmL-450290765 160ms cubic-bezier(0,0,0.2,1) 90ms both;
+}
+
+.c13.c13.c13.c13.c13 > * {
+ max-width: 100%;
+ max-height: 100%;
+}
+
+.c6.c6.c6.c6.c6 .face {
+ -webkit-transform-box: fill-box;
+ -ms-transform-box: fill-box;
+ transform-box: fill-box;
+ -webkit-transform-origin: center;
+ -ms-transform-origin: center;
+ transform-origin: center;
+}
+
+.c6.c6.c6.c6.c6 .tear {
+ -webkit-transform-box: fill-box;
+ -ms-transform-box: fill-box;
+ transform-box: fill-box;
+ -webkit-transform-origin: 50% 0%;
+ -ms-transform-origin: 50% 0%;
+ transform-origin: 50% 0%;
+}
+
+button:hover:not(:disabled) .c6.c6.c6.c6.c6 .face,
+button:focus-visible .c6.c6.c6.c6.c6 .face,
+button[aria-checked='true'] .c6.c6.c6.c6.c6 .face {
+ -webkit-animation: guTmwE-450290765 2.8s ease-in-out infinite;
+ animation: guTmwE-450290765 2.8s ease-in-out infinite;
+}
+
+button:hover:not(:disabled) .c6.c6.c6.c6.c6 .tear,
+button:focus-visible .c6.c6.c6.c6.c6 .tear,
+button[aria-checked='true'] .c6.c6.c6.c6.c6 .tear {
+ -webkit-animation: TeClM-450290765 2.8s linear infinite;
+ animation: TeClM-450290765 2.8s linear infinite;
+}
+
+.c9.c9.c9.c9.c9 {
+ overflow: visible;
+}
+
+.c9.c9.c9.c9.c9 .face {
+ -webkit-transform-box: fill-box;
+ -ms-transform-box: fill-box;
+ transform-box: fill-box;
+ -webkit-transform-origin: center;
+ -ms-transform-origin: center;
+ transform-origin: center;
+}
+
+.c9.c9.c9.c9.c9 .thumb {
+ -webkit-transform-box: fill-box;
+ -ms-transform-box: fill-box;
+ transform-box: fill-box;
+ -webkit-transform-origin: 50% 8%;
+ -ms-transform-origin: 50% 8%;
+ transform-origin: 50% 8%;
+}
+
+button:hover:not(:disabled) .c9.c9.c9.c9.c9 .thumb,
+button:focus-visible .c9.c9.c9.c9.c9 .thumb,
+button[aria-checked='true'] .c9.c9.c9.c9.c9 .thumb {
+ -webkit-animation: cipsOa-450290765 0.6s forwards;
+ animation: cipsOa-450290765 0.6s forwards;
+}
+
+button:hover:not(:disabled) .c9.c9.c9.c9.c9 .face,
+button:focus-visible .c9.c9.c9.c9.c9 .face,
+button[aria-checked='true'] .c9.c9.c9.c9.c9 .face {
+ -webkit-animation: hNtKvL-450290765 0.6s cubic-bezier(0.33,0,0.3,1) forwards;
+ animation: hNtKvL-450290765 0.6s cubic-bezier(0.33,0,0.3,1) forwards;
+}
+
+.c12.c12.c12.c12.c12 .thumb {
+ -webkit-transform-box: fill-box;
+ -ms-transform-box: fill-box;
+ transform-box: fill-box;
+ -webkit-transform-origin: 50% 92%;
+ -ms-transform-origin: 50% 92%;
+ transform-origin: 50% 92%;
+}
+
+button:hover:not(:disabled) .c12.c12.c12.c12.c12 .thumb,
+button:focus-visible .c12.c12.c12.c12.c12 .thumb,
+button[aria-checked='true'] .c12.c12.c12.c12.c12 .thumb {
+ -webkit-animation: kBdBkO-450290765 0.6s forwards;
+ animation: kBdBkO-450290765 0.6s forwards;
+}
+
+.c14.c14.c14.c14.c14 .heart {
+ -webkit-transform-box: fill-box;
+ -ms-transform-box: fill-box;
+ transform-box: fill-box;
+ -webkit-transform-origin: center;
+ -ms-transform-origin: center;
+ transform-origin: center;
+}
+
+button:hover:not(:disabled) .c14.c14.c14.c14.c14 .heart,
+button:focus-visible .c14.c14.c14.c14.c14 .heart,
+button[aria-checked='true'] .c14.c14.c14.c14.c14 .heart {
+ -webkit-animation: gBVWHx-450290765 0.5s cubic-bezier(0.34,1.56,0.64,1) forwards,cAtlcO-450290765 1s ease-in-out 0.5s infinite;
+ animation: gBVWHx-450290765 0.5s cubic-bezier(0.34,1.56,0.64,1) forwards,cAtlcO-450290765 1s ease-in-out 0.5s infinite;
+}
+
+button:hover:not(:disabled) .c14.c14.c14.c14.c14 .right,
+button:focus-visible .c14.c14.c14.c14.c14 .right,
+button[aria-checked='true'] .c14.c14.c14.c14.c14 .right {
+ -webkit-animation-delay: 0.05s,0.55s;
+ animation-delay: 0.05s,0.55s;
+}
+
+@media (prefers-reduced-motion:reduce) {
+ .c5.c5.c5.c5.c5 {
+ -webkit-animation-name: none;
+ animation-name: none;
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion:reduce) {
+ .c8.c8.c8.c8.c8 {
+ -webkit-animation-name: none;
+ animation-name: none;
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion:reduce) {
+ .c11.c11.c11.c11.c11 {
+ -webkit-animation-name: none;
+ animation-name: none;
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion:reduce) {
+ .c13.c13.c13.c13.c13 {
+ -webkit-animation-name: none;
+ animation-name: none;
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion:reduce) {
+ .c6.c6.c6.c6.c6 * {
+ -webkit-animation: none !important;
+ animation: none !important;
+ }
+}
+
+@media (prefers-reduced-motion:reduce) {
+ .c9.c9.c9.c9.c9 * {
+ -webkit-animation: none !important;
+ animation: none !important;
+ }
+}
+
+@media (prefers-reduced-motion:reduce) {
+ .c12.c12.c12.c12.c12 * {
+ -webkit-animation: none !important;
+ animation: none !important;
+ }
+}
+
+@media (prefers-reduced-motion:reduce) {
+ .c14.c14.c14.c14.c14 * {
+ -webkit-animation: none !important;
+ animation: none !important;
+ }
+}
+
+
+
+
+
+
+ How are we doing so far?
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/packages/blade/src/components/ChatFeedback/_decisions/decisions.md b/packages/blade/src/components/ChatFeedback/_decisions/decisions.md
new file mode 100644
index 0000000000..347c3f9789
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/_decisions/decisions.md
@@ -0,0 +1,274 @@
+# ChatFeedback 🙂
+
+A compact, inline feedback flow for conversational surfaces. It walks the user from a four-point
+mood scale, to a quick tag follow-up, to a confirmation — and then leaves. It draws no surface of
+its own and it never removes itself.
+
+Currently **web only**; the native counterpart throws until it is implemented.
+
+## Design
+
+- Figma: `Agentic Dashboard — Working File`, node `380:81103`
+- The attached-to-a-composer arrangement is `ChatInput`'s `feedback` prop, specified at the end of
+ this document.
+
+## States
+
+Three steps, one direction of travel, and one way back.
+
+```
+ ┌────────────── goBackToMood ──────────────┐
+ ▼ │
+ ┌──────┐ selectMood ┌──────┐ submitTags ┴─────────┐ after 1.3s ┌──────────┐
+ │ mood │ ─────────────► │ tags │ ───────────► │ thanks │ ─────────────► │ dismissed │
+ └──────┘ (+200ms) └──────┘ └─────────┘ autoDismiss └──────────┘
+```
+
+`dismissed` is not a step. The component has no such state — it fires `onDismiss` and keeps
+rendering the confirmation until the host stops rendering it. That is deliberate: a component that
+removes itself cannot be animated out by whatever contains it.
+
+### mood
+
+| | |
+|---|---|
+| Shows | the question, and four buttons carrying `feedbackIcons` |
+| Sub-states | nothing selected · one selected (200ms, then `tags`) |
+| Emits | `onMoodSelect` |
+
+The 200ms hold exists so the selection is seen before the step changes. It is a `theme.motion.duration.quick` beat, not a magic number.
+
+### tags
+
+| | |
+|---|---|
+| Shows | back control, follow-up question, up to four chips, and the submit |
+| Sub-states | nothing selected · some selected · **only the free-text tag** · mixed |
+| Emits | `onTagsChange` on **every** change, `onSubmit` on submit |
+
+Selecting a mood, or going back, also clears the tags — and both report through `onTagsChange`.
+This matters more than it looks: see *Reporting* below.
+
+### thanks
+
+| | |
+|---|---|
+| Shows | a filled check and one line, whose copy follows the mood |
+| Held for | `chatFeedbackThanksDurationMs` (1300ms) when `autoDismiss` |
+| Emits | `onDismiss` |
+
+Copy resolution is `thanksLabel` prop → `moodConfig[mood].thanksLabel` → a generic default. The
+per-mood defaults differ by sentiment: someone who has just said the assistant got it wrong is
+acknowledged, not thanked for a lovely contribution.
+
+### Orthogonal to the steps
+
+- **`isDisabled`** — every control inert, at any step.
+- **`isFullWidth`** — each step spreads edge to edge, or hugs its content.
+- **`isSubmitHidden`** — the step keeps its state; only the tick goes.
+
+## Invariants
+
+These are the properties worth testing, and the ones that were broken at some point during the
+build:
+
+1. **A tap resolves to the mood it looks like.** Targets are 32px around a 20px glyph, butted
+ together for a 32px pitch. On a four-point scale, landing on the neighbour is a wrong answer,
+ not a near miss.
+2. **Selection is visible without relying on the glyph.** Supplied artwork may be untintable and
+ has no filled twin, so hover and selected are drawn on the *button*.
+3. **The flow never traps the user.** Every state has a way forward or back. This is the invariant
+ the free-text arrangement broke three separate times.
+4. **The host is never told less than the truth.** Any change to the selection is reported.
+5. **Nothing is announced twice.** The mood's name is the button's accessible name; supplied
+ artwork is `aria-hidden`.
+
+## Reporting
+
+`onTagsChange` fires from a single setter inside the hook, not from the chip group. Every route
+that mutates the selection — the chips, picking a new mood, going back — goes through it.
+
+This was originally wired to the chip group alone, and the two internal clears were silent. A host
+mirroring the selection then held tags that no longer existed and acted on them: `ChatInput` kept
+its composer in feedback mode after the strip had walked back to the mood step, with no tag
+selected and no way out. One reporting path makes that drift impossible rather than unlikely.
+
+**Corollary for hosts:** read the mode you are driving from a ref, not from state. These callbacks
+can be invoked from a memoised closure belonging to an earlier render, and a state copy read there
+may be stale. That is the second way the same bug appeared.
+
+## Proposed API
+
+```jsx
+ record(mood, tags, comment)}
+ onDismiss={() => setShowFeedback(false)}
+/>
+```
+
+### Props
+
+```ts
+type ChatFeedbackMood = 'very-dissatisfied' | 'dissatisfied' | 'satisfied' | 'very-satisfied';
+type ChatFeedbackStep = 'mood' | 'tags' | 'thanks';
+type ChatFeedbackIcons = Record;
+
+type ChatFeedbackMoodConfig = {
+ /** Follow-up question shown once this mood is picked. */
+ question: string;
+ /** Quick-select tags offered for this mood. */
+ tags: string[];
+ /** Closing line for this mood. Defaults differ by sentiment. */
+ thanksLabel?: string;
+};
+
+type ChatFeedbackSubmitPayload = {
+ mood: ChatFeedbackMood;
+ /** Tags selected. Empty when submitted without picking any. */
+ tags: string[];
+ /** Free text, present only when the host collected some. */
+ comment?: string;
+};
+
+/** The parts of a running flow a host may need to drive. */
+type ChatFeedbackControls = {
+ submit: () => void;
+ setTags: (tags: string[]) => void;
+};
+
+type ChatFeedbackProps = {
+ /** @default "How are we doing so far?" */
+ question?: string;
+
+ /**
+ * Artwork for the scale, one entry per mood. Optional — Blade ships an animated set and uses it
+ * when omitted. Rendered as given, in a fixed box, hidden from assistive technology.
+ */
+ feedbackIcons?: ChatFeedbackIcons;
+
+ /** Overrides follow-up copy, tags and closing line per mood. Partial: unlisted moods keep defaults. */
+ moodConfig?: Partial>;
+
+ /** Closing line for every mood. Unset, the copy follows the mood. */
+ thanksLabel?: string;
+
+ onMoodSelect?: ({ mood }: { mood: ChatFeedbackMood }) => void;
+ /** Fires on **every** change to the selection, from any route. */
+ onTagsChange?: ({ tags }: { tags: string[] }) => void;
+ onSubmit?: (payload: ChatFeedbackSubmitPayload) => void;
+ /** The flow is finished. It does **not** remove itself — stop rendering it here. */
+ onDismiss?: () => void;
+
+ /** Dismisses itself 1.3s after the confirmation. @default true */
+ autoDismiss?: boolean;
+ /** Steps spread edge to edge, or hug their content. @default true */
+ isFullWidth?: boolean;
+ /** @default false */
+ isDisabled?: boolean;
+
+ /** Hides this flow's own tick, when the host is showing one. @default false */
+ isSubmitHidden?: boolean;
+ /** Receives a handle on the running flow, for a host with controls of its own. */
+ controlsRef?: React.MutableRefObject;
+ /** Free text gathered by the host, folded into the submit payload. */
+ comment?: string;
+} & TestID &
+ DataAnalyticsAttribute &
+ StyledPropsBlade;
+```
+
+## Host contract: `ChatInput`'s `feedback`
+
+```jsx
+ record(mood, tags, comment),
+ onDismiss: () => setShowFeedback(false),
+ }}
+/>
+```
+
+```ts
+type ChatInputFeedbackProps = Pick<
+ ChatFeedbackProps,
+ 'question' | 'moodConfig' | 'feedbackIcons' | 'isDisabled' | 'onMoodSelect' | 'onSubmit' | 'onDismiss'
+> & {
+ /** @default true */
+ isVisible?: boolean;
+ /** The tag that collects free text instead of standing alone. @default 'Other' */
+ freeTextTag?: string;
+ /** Placeholder while the composer is collecting that text. @default 'Anything else? (optional)' */
+ commentPlaceholder?: string;
+};
+```
+
+Passing the object is the switch — there is no `showFeedback` boolean, in the same way `Tooltip`
+has no `showTitle`. Omit it and the composer renders byte-for-byte as it does without the feature;
+the surface is only drawn while the prompt is showing.
+
+`isFullWidth`, `isSubmitHidden`, `controlsRef`, `comment` and `onTagsChange` are deliberately **not**
+forwarded: they describe how the flow is laid out and driven, which is the composer's business, not
+the caller's.
+
+### The composer takeover
+
+Picking `freeTextTag` hands the composer over:
+
+| | |
+|---|---|
+| Placeholder | becomes `commentPlaceholder` |
+| Chat draft | stashed, and restored on the way out |
+| Action bar | upload link replaced by a dismissable `Feedback` tag and an `esc to cancel` hint |
+| Focus | moves to the composer |
+| Enter | submits the feedback. The chat path is **blocked**, not redirected |
+| The tick | hidden — the composer's send arrow is the submit |
+| Exits | Esc · the tag's ✕ · submitting · deselecting the tag · the prompt going away |
+
+Every exit also **releases the tag**. Leaving it selected strands the user: the tick stays hidden
+because the only tag picked is the free-text one, and the composer has gone back to chatting — a
+choice made with no way left to send it.
+
+Enter is blocked rather than rerouted because sending someone's candid feedback to the assistant as
+a prompt is not a recoverable mistake.
+
+## Alternatives considered
+
+**A slot for the whole strip** (`header?: ReactNode`) — smaller API, and it would have kept
+`ChatFeedback` out of Blade entirely. Rejected because a `ReactNode` slot has no representation in
+the Figma DSL: designers could neither toggle nor preview it, and code/design parity breaks. It
+would also hand every consumer the a11y, hit targets and selected state — the three things that
+were hardest to get right here.
+
+**A slot for the scale.** Same objection at smaller scale, plus it hides a contract types cannot
+express ("an SVG that inherits `currentColor`"). `feedbackIcons` swaps the glyphs, not the control.
+
+**`moodScale: 'faces' | 'thumbs'`.** Built, then removed: both values depended on artwork that did
+not exist at the time. A switch with no working position is worse than no switch. Blade now ships a
+single animated set as the default, and `feedbackIcons` is the override — if a second named set
+ever earns its place, `moodScale` can return alongside it.
+
+**Its own free-text step.** The component used to own a `comment` step behind an
+"Add more feedback" link. Removed: two places to type stacked vertically, and the one that looks
+like the composer was not the one with focus.
+
+## Open questions
+
+1. **The host coupling is four props** — `onTagsChange`, `isSubmitHidden`, `controlsRef`, `comment`
+ — which together express one idea: *the surrounding surface is collecting the free text*. It
+ works, and every piece is load-bearing, but a single higher-level prop would be harder to
+ misuse. Worth revisiting once a second host exists; premature to design for one.
+2. **`controlsRef` is a prop rather than a forwarded ref.** The handle itself is published from a
+ layout effect and delegates through refs, so it is neither written during render nor ever stale.
+ A forwarded ref with `useImperativeHandle` would be the more idiomatic shape, but it would make
+ the flow's identity a ref — which `ChatInput` already spends on the composer's own input.
+3. **`freeTextTag` fails silently** when it matches no tag in `moodConfig` — the composer simply
+ never engages. A `__DEV__` warning would fail fast instead.
+4. **Native.** The whole component throws. The mood scale and tags step have no platform-specific
+ requirement; the composer takeover does.
+5. **Standalone free-text.** With the comment step gone, a surface without a composer — a floating
+ bar, say — cannot collect free text at all.
diff --git a/packages/blade/src/components/ChatFeedback/chatFeedbackTokens.ts b/packages/blade/src/components/ChatFeedback/chatFeedbackTokens.ts
new file mode 100644
index 0000000000..f6dd83eebe
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/chatFeedbackTokens.ts
@@ -0,0 +1,181 @@
+import type { ChatFeedbackMood, ChatFeedbackMoodConfig } from './types';
+import type { IconProps } from '~components/Icons';
+
+/** Worst to best. Drives render order of the scale. */
+const chatFeedbackMoods: ChatFeedbackMood[] = [
+ 'very-dissatisfied',
+ 'dissatisfied',
+ 'satisfied',
+ 'very-satisfied',
+];
+
+/**
+ * ## Blade ships no artwork for this scale yet
+ *
+ * The four points, their colours and their names are settled; the glyphs are not. Rather than
+ * ship a placeholder set and have products build on it, the component asks for the artwork:
+ * `feedbackIcons` is required, and this table carries everything *except* the icons.
+ *
+ * When a designed set does land it goes here, `feedbackIcons` becomes optional, and a `moodScale`
+ * prop picks between the sets. That direction is safe — a required prop becoming optional breaks
+ * no one, which is why the artwork is missing rather than provisional.
+ *
+ * Blade's feedback ramp has three usable sentiments, so `satisfied` and `very-satisfied` share
+ * `positive`; the design distinguished them with a yellow-green, which has no token equivalent.
+ */
+/** `currentColor` excluded so the value can also be resolved against the theme for CSS. */
+type ChatFeedbackMoodColor = Exclude;
+
+/** The three sentiments the four moods map onto, as background tokens. */
+type ChatFeedbackMoodSurfaceColor = `feedback.background.${
+ | 'positive'
+ | 'negative'
+ | 'notice'}.subtle`;
+
+const chatFeedbackMoodTokens: Record<
+ ChatFeedbackMood,
+ {
+ color: ChatFeedbackMoodColor;
+ /**
+ * Filled behind the button when the mood is hovered or picked.
+ *
+ * Selection used to be carried entirely by the glyph — recoloured, and swapped for its filled
+ * twin. That only works while Blade owns the artwork. A consumer supplying its own icons
+ * through `feedbackIcons` may pass something that cannot be tinted at all (a system emoji, a
+ * raster), and then two of the three selected cues silently vanish, leaving a 12% scale as the
+ * only sign that an answer registered. Putting the state on the button instead means it reads
+ * the same whatever is sitting on top of it.
+ */
+ surfaceColor: ChatFeedbackMoodSurfaceColor;
+ label: string;
+ }
+> = {
+ 'very-dissatisfied': {
+ color: 'feedback.icon.negative.intense',
+ surfaceColor: 'feedback.background.negative.subtle',
+ label: 'Terrible',
+ },
+ dissatisfied: {
+ color: 'feedback.icon.notice.intense',
+ surfaceColor: 'feedback.background.notice.subtle',
+ label: 'Bad',
+ },
+ satisfied: {
+ color: 'feedback.icon.positive.intense',
+ surfaceColor: 'feedback.background.positive.subtle',
+ label: 'Good',
+ },
+ 'very-satisfied': {
+ color: 'feedback.icon.positive.intense',
+ surfaceColor: 'feedback.background.positive.subtle',
+ label: 'Love it!',
+ },
+};
+
+/**
+ * Default follow-up copy. Deliberately product-agnostic — consumers override via `moodConfig`.
+ */
+const chatFeedbackDefaultMoodConfig: Record = {
+ 'very-dissatisfied': {
+ question: 'Sorry to hear that. What went wrong?',
+ tags: ['Wrong answers', 'Too slow', 'Inaccurate', 'Other'],
+ thanksLabel: "Thanks for flagging it — we'll look into this.",
+ },
+ dissatisfied: {
+ question: 'Thanks. What could be better?',
+ tags: ['Accuracy', 'Speed', 'Tone', 'Other'],
+ thanksLabel: "Thanks — we'll work on this.",
+ },
+ satisfied: {
+ question: 'Glad it helped! What worked well?',
+ tags: ['Helpful', 'Fast', 'Clear', 'Other'],
+ thanksLabel: 'Thanks for the feedback!',
+ },
+ 'very-satisfied': {
+ question: 'Love it! What stood out most?',
+ tags: ['Nailed it', 'Super fast', 'Great tone', 'Other'],
+ thanksLabel: 'Thanks — glad it helped!',
+ },
+};
+
+/**
+ * Chip size used by the tags step. Exported so the alignment fix in `ChatFeedback` can read
+ * the matching `chipGroupGapTokens` entry instead of hardcoding a value that would silently
+ * drift if this size ever changes.
+ */
+const chatFeedbackChipSize = 'xsmall' as const;
+
+/**
+ * How long the thank-you step is held before the flow dismisses itself.
+ *
+ * Long enough to be read — the previous value came from `motion.delay.xgentle` (960ms), and on a
+ * strip that is also fading out the confirmation was gone before it registered. Short enough that
+ * nobody waits on it: this sits above a composer someone is trying to type in.
+ *
+ * A literal rather than a delay token because the scale steps 960 → 2000 with nothing between,
+ * and both ends are wrong here. If a token lands in that gap, this should become it.
+ */
+const chatFeedbackThanksDurationMs = 1300;
+
+/**
+ * Edge of the square a mood glyph is drawn in.
+ *
+ * Off the spacing scale, which stops at 24 and jumps to 32. A face is not an icon: an icon is one
+ * shape read at a glance, while these carry eyes, a mouth and — on two of them — a thumb, and
+ * every one has to survive the same pass. At the 20px this used to be, the faces landed around
+ * 15px and read as coloured dots rather than as expressions.
+ */
+const chatFeedbackMoodGlyphSize = 28;
+
+/**
+ * Height of a mood button, and so of the tallest step the strip has to hold.
+ *
+ * The glyph plus `spacing[3]` of padding on each side.
+ *
+ * It is exported because the strip must reserve this much for *every* step, not just the mood
+ * one. The three steps have different natural heights, and a strip that only reserves what the
+ * current step needs changes height on each swap — pushing the composer below it up and down at
+ * the exact moment someone is reading the step that just replaced the last one.
+ *
+ * Anything that sets the strip's height reads it from here so the two cannot drift, which they
+ * did once already: the glyph grew from 20px and the reserved height was left behind, and the
+ * composer started jumping 12px on every transition.
+ */
+const chatFeedbackMoodButtonSize = chatFeedbackMoodGlyphSize + 8 * 2;
+
+/**
+ * The tinted disc shown behind a glyph on hover, focus and selection.
+ *
+ * Smaller than the button on purpose. The button's size is a tap target and belongs at 44px; the
+ * disc is decoration. Sizing the disc off the button would force a choice between a comfortable
+ * target and a tidy circle — shrinking the target to tighten the circle is what let the
+ * adjacent-rating mis-taps back in once already.
+ *
+ * At 36px it clears the 28px glyph by 4px on every side: enough to read as a halo behind the face
+ * rather than as a chip around it, and still 4px inside the button it sits in.
+ */
+const chatFeedbackMoodDiscSize = 36;
+
+/**
+ * Space the tags step holds open for its submit control.
+ *
+ * Blade's `xsmall` icon-only `Button` measures 28px, plus `spacing[3]` of gap before it.
+ *
+ * It is reserved rather than occupied. The control is revealed on the first tag pick, and a button
+ * that arrives *in* the flex row shoves every chip 36px to the left at the moment the user is
+ * reading them — measured, not assumed. Holding the space and fading the control into it means the
+ * row makes room instead of being pushed aside.
+ */
+const chatFeedbackSubmitRevealWidth = 28 + 8;
+
+export {
+ chatFeedbackMoods,
+ chatFeedbackMoodTokens,
+ chatFeedbackDefaultMoodConfig,
+ chatFeedbackChipSize,
+ chatFeedbackThanksDurationMs,
+ chatFeedbackMoodGlyphSize,
+ chatFeedbackMoodButtonSize,
+ chatFeedbackMoodDiscSize,
+ chatFeedbackSubmitRevealWidth,
+};
diff --git a/packages/blade/src/components/ChatFeedback/docs/ChatFeedback.stories.tsx b/packages/blade/src/components/ChatFeedback/docs/ChatFeedback.stories.tsx
new file mode 100644
index 0000000000..06bc702170
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/docs/ChatFeedback.stories.tsx
@@ -0,0 +1,113 @@
+import type { StoryFn, Meta } from '@storybook/react-vite';
+import React, { useState } from 'react';
+import { ChatFeedback } from '../ChatFeedback';
+import type { ChatFeedbackProps } from '../types';
+import { Box } from '~components/Box';
+import { Button } from '~components/Button';
+import { Heading } from '~components/Typography';
+import { getStyledPropsArgTypes } from '~components/Box/BaseBox/storybookArgTypes';
+import StoryPageWrapper from '~utils/storybook/StoryPageWrapper';
+import { Sandbox } from '~utils/storybook/Sandbox';
+
+const Page = (): React.ReactElement => {
+ return (
+
+ Usage
+
+ {`
+ import { ChatFeedback } from '@razorpay/blade/components';
+
+ function App() {
+ const [show, setShow] = React.useState(true);
+
+ if (!show) return null;
+
+
+ return (
+ console.log('feedback', payload)}
+ onDismiss={() => setShow(false)}
+ />
+ );
+ }
+
+ export default App;
+ `}
+
+
+ );
+};
+
+export default {
+ title: 'Components/ChatFeedback',
+ component: ChatFeedback,
+ tags: ['autodocs'],
+ argTypes: {
+ ...getStyledPropsArgTypes(),
+ },
+ parameters: {
+ docs: { page: Page },
+ },
+} as Meta;
+
+/**
+ * ChatFeedback does not remove itself — it fires `onDismiss` when the flow ends and the
+ * consumer hides it. The replay button remounts a fresh flow, since no state survives unmount.
+ */
+const ChatFeedbackTemplate: StoryFn = (args) => {
+ const [show, setShow] = useState(true);
+
+ return (
+
+ {show ? (
+ {
+ // eslint-disable-next-line no-console
+ console.log('onSubmit', payload);
+ }}
+ onDismiss={() => setShow(false)}
+ />
+ ) : (
+
+ )}
+
+ );
+};
+
+export const Default = ChatFeedbackTemplate.bind({});
+Default.args = { question: "How's Ray doing so far?" };
+Default.storyName = 'Default';
+
+export const WithoutAutoDismiss = ChatFeedbackTemplate.bind({});
+WithoutAutoDismiss.args = {
+ question: "How's Ray doing so far?",
+ // Nothing hides the flow after the thank-you; the consumer stays in control.
+ autoDismiss: false,
+};
+WithoutAutoDismiss.storyName = 'Without auto dismiss';
+
+export const CustomMoodConfig = ChatFeedbackTemplate.bind({});
+CustomMoodConfig.args = {
+ question: 'How was this answer?',
+ // Only the moods you pass are overridden; the rest keep their defaults.
+ moodConfig: {
+ 'very-satisfied': {
+ question: 'Amazing! What made it great?',
+ tags: ['Accurate', 'Well written', 'Saved me time', 'Other'],
+ },
+ 'very-dissatisfied': {
+ question: 'Sorry about that. What broke?',
+ tags: ['Wrong', 'Off-topic', 'Too vague', 'Other'],
+ },
+ },
+};
+CustomMoodConfig.storyName = 'Custom mood config';
diff --git a/packages/blade/src/components/ChatFeedback/index.ts b/packages/blade/src/components/ChatFeedback/index.ts
new file mode 100644
index 0000000000..3a8db9fe67
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/index.ts
@@ -0,0 +1,9 @@
+export type {
+ ChatFeedbackControls,
+ ChatFeedbackProps,
+ ChatFeedbackMood,
+ ChatFeedbackMoodConfig,
+ ChatFeedbackStep,
+ ChatFeedbackSubmitPayload,
+} from './types';
+export { ChatFeedback } from './ChatFeedback';
diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/BadIcon.tsx b/packages/blade/src/components/ChatFeedback/moodIcons/BadIcon.tsx
new file mode 100644
index 0000000000..b194346e7b
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/moodIcons/BadIcon.tsx
@@ -0,0 +1,141 @@
+/**
+ * Thumbs-down over a disappointed face.
+ *
+ * Web only: the motion is CSS driven by the mood button's own hover, focus and selected states,
+ * which styled-components/native cannot express.
+ *
+ * The button owns the hover target, not this glyph. A 20px face inside a 32px control means
+ * hovering the padding — most of the control — would leave the icon inert if the animation hung
+ * off the SVG itself, so every rule below is keyed on an ancestor `button` state instead.
+ */
+import React from 'react';
+import styled, { keyframes } from 'styled-components';
+import type { MoodIconProps } from './types';
+import { WHEN_ACTIVE } from './whenActive';
+
+/* Mirrors GoodIcon exactly, negated: the pair is one gesture in two directions. */
+const reject = keyframes`
+ 0% {
+ transform: translateY(0) rotate(0deg) scale(1);
+ animation-timing-function: cubic-bezier(0.33, 0, 0.67, 1);
+ }
+ 18% {
+ transform: translateY(-0.22px) rotate(-1.5deg) scale(0.99);
+ animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.6);
+ }
+ 52% {
+ transform: translateY(1.15px) rotate(6deg) scale(1.12);
+ animation-timing-function: cubic-bezier(0.2, 0.8, 0.4, 1);
+ }
+ 74% { transform: translateY(0.6px) rotate(2.5deg) scale(1.06); }
+ 100% { transform: translateY(0.85px) rotate(4.5deg) scale(1.09); }
+`;
+
+const recoil = keyframes`
+ 0% { transform: translate(0, 0); }
+ 55% { transform: translate(0.22px, 0.36px); }
+ 100% { transform: translate(0.16px, 0.28px); }
+`;
+
+const Svg = styled.svg`
+ /*
+ * Centred on the resting composite, which puts the press about a pixel past the box at its
+ * deepest. Nothing between here and the button clips — the slot is an inline-flex span and the
+ * button sets no overflow — so the frame is allowed to paint rather than shifting the artwork
+ * off-centre for the other 99% of the time it is sitting still.
+ */
+ overflow: visible;
+
+ .face {
+ transform-box: fill-box;
+ transform-origin: center;
+ }
+
+ /* Pivots at the wrist, which sits at the top of a thumb pointing down. */
+ .thumb {
+ transform-box: fill-box;
+ transform-origin: 50% 8%;
+ }
+
+ ${WHEN_ACTIVE} {
+ .thumb {
+ animation: ${reject} 0.6s forwards;
+ }
+
+ .face {
+ animation: ${recoil} 0.6s cubic-bezier(0.33, 0, 0.3, 1) forwards;
+ }
+ }
+ /* No Blade token for this yet, so the query is inline. */
+ @media (prefers-reduced-motion: reduce) {
+ * {
+ animation: none !important;
+ }
+ }
+`;
+
+const BadIcon = ({ size = 28 }: MoodIconProps): React.ReactElement => {
+ const gradientId = React.useId();
+
+ return (
+
+ );
+};
+
+export { BadIcon };
diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/GoodIcon.tsx b/packages/blade/src/components/ChatFeedback/moodIcons/GoodIcon.tsx
new file mode 100644
index 0000000000..b051bef17f
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/moodIcons/GoodIcon.tsx
@@ -0,0 +1,101 @@
+/**
+ * Thumbs-up over a beaming face.
+ *
+ * Web only: the motion is CSS driven by the mood button's own hover, focus and selected states,
+ * which styled-components/native cannot express.
+ *
+ * The button owns the hover target, not this glyph. A 20px face inside a 32px control means
+ * hovering the padding — most of the control — would leave the icon inert if the animation hung
+ * off the SVG itself, so every rule below is keyed on an ancestor `button` state instead.
+ */
+import React from 'react';
+import styled, { keyframes } from 'styled-components';
+import type { MoodIconProps } from './types';
+import { WHEN_ACTIVE } from './whenActive';
+
+const approve = keyframes`
+ 0% {
+ transform: translateY(0) rotate(0deg) scale(1);
+ animation-timing-function: cubic-bezier(0.33, 0, 0.67, 1);
+ }
+ 18% {
+ transform: translateY(0.22px) rotate(1.5deg) scale(0.99);
+ animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.6);
+ }
+ 52% {
+ transform: translateY(-1.15px) rotate(-6deg) scale(1.12);
+ animation-timing-function: cubic-bezier(0.2, 0.8, 0.4, 1);
+ }
+ 74% { transform: translateY(-0.6px) rotate(-2.5deg) scale(1.06); }
+ 100% { transform: translateY(-0.85px) rotate(-4.5deg) scale(1.09); }
+`;
+
+const Svg = styled.svg`
+ /* Pivots at the wrist, at the bottom of a thumb pointing up. */
+ .thumb {
+ transform-box: fill-box;
+ transform-origin: 50% 92%;
+ }
+
+ ${WHEN_ACTIVE} {
+ .thumb {
+ animation: ${approve} 0.6s forwards;
+ }
+ }
+ /* No Blade token for this yet, so the query is inline. */
+ @media (prefers-reduced-motion: reduce) {
+ * {
+ animation: none !important;
+ }
+ }
+`;
+
+const GoodIcon = ({ size = 28 }: MoodIconProps): React.ReactElement => {
+ return (
+
+ );
+};
+
+export { GoodIcon };
diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/LoveItIcon.tsx b/packages/blade/src/components/ChatFeedback/moodIcons/LoveItIcon.tsx
new file mode 100644
index 0000000000..3dedb23326
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/moodIcons/LoveItIcon.tsx
@@ -0,0 +1,107 @@
+/**
+ * Heart eyes — the top of the scale.
+ *
+ * Web only: the motion is CSS driven by the mood button's own hover, focus and selected states,
+ * which styled-components/native cannot express.
+ *
+ * The button owns the hover target, not this glyph. A 20px face inside a 32px control means
+ * hovering the padding — most of the control — would leave the icon inert if the animation hung
+ * off the SVG itself, so every rule below is keyed on an ancestor `button` state instead.
+ */
+import React from 'react';
+import styled, { keyframes } from 'styled-components';
+import type { MoodIconProps } from './types';
+import { WHEN_ACTIVE } from './whenActive';
+
+const pop = keyframes`
+ 0% { transform: scale(1); }
+ 38% { transform: scale(1.28); }
+ 64% { transform: scale(1.1); }
+ 84% { transform: scale(1.2); }
+ 100% { transform: scale(1.14); }
+`;
+
+const beat = keyframes`
+ 0%, 100% { transform: scale(1.14); }
+ 20% { transform: scale(1.26); }
+ 36% { transform: scale(1.14); }
+ 52% { transform: scale(1.21); }
+ 70% { transform: scale(1.14); }
+`;
+
+const Svg = styled.svg`
+ .heart {
+ transform-box: fill-box;
+ transform-origin: center;
+ }
+
+ ${WHEN_ACTIVE} {
+ .heart {
+ animation: ${pop} 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards,
+ ${beat} 1s ease-in-out 0.5s infinite;
+ }
+
+ /* Offset so the pair beats as two hearts rather than one wide shape. */
+ .right {
+ animation-delay: 0.05s, 0.55s;
+ }
+ }
+ /* No Blade token for this yet, so the query is inline. */
+ @media (prefers-reduced-motion: reduce) {
+ * {
+ animation: none !important;
+ }
+ }
+`;
+
+const LoveItIcon = ({ size = 28 }: MoodIconProps): React.ReactElement => {
+ return (
+
+ );
+};
+
+export { LoveItIcon };
diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/TerribleIcon.tsx b/packages/blade/src/components/ChatFeedback/moodIcons/TerribleIcon.tsx
new file mode 100644
index 0000000000..6b1d13d395
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/moodIcons/TerribleIcon.tsx
@@ -0,0 +1,138 @@
+/**
+ * Crying face — the worst rung of the scale.
+ *
+ * Web only: the motion is CSS driven by the mood button's own hover, focus and selected states,
+ * which styled-components/native cannot express.
+ *
+ * The button owns the hover target, not this glyph. A 20px face inside a 32px control means
+ * hovering the padding — most of the control — would leave the icon inert if the animation hung
+ * off the SVG itself, so every rule below is keyed on an ancestor `button` state instead.
+ */
+import React from 'react';
+import styled, { keyframes } from 'styled-components';
+import type { MoodIconProps } from './types';
+import { WHEN_ACTIVE } from './whenActive';
+
+const droop = keyframes`
+ 0%, 100% { transform: translateY(0) scaleY(1); }
+ 55% { transform: translateY(0.35px) scaleY(0.985); }
+`;
+
+/* Starts and ends on the resting pose, so hovering on or off never jumps the tear. */
+const fall = keyframes`
+ 0% { transform: translate(0, 0) scale(1); opacity: 1; }
+ 18% { transform: translate(0.15px, 0.3px) scale(0.85); opacity: 0; }
+ 19% { transform: translate(-0.8px, -4.2px) scale(0.34); opacity: 0; }
+ 26% { transform: translate(-0.75px, -4px) scale(0.5); opacity: 1; }
+ 40% {
+ transform: translate(-0.55px, -3.1px) scale(0.7);
+ opacity: 1;
+ animation-timing-function: cubic-bezier(0.5, 0, 0.85, 0.45);
+ }
+ 78% { transform: translate(-0.05px, -0.15px) scale(1.02); opacity: 1; }
+ 94% { transform: translate(0, 0) scale(1.02); opacity: 1; }
+ 100% { transform: translate(0, 0) scale(1); opacity: 1; }
+`;
+
+const Svg = styled.svg`
+ .face {
+ transform-box: fill-box;
+ transform-origin: center;
+ }
+
+ /* The tear stays visible at rest — without it this reads as a plain sad face. */
+ .tear {
+ transform-box: fill-box;
+ transform-origin: 50% 0%;
+ }
+
+ ${WHEN_ACTIVE} {
+ .face {
+ animation: ${droop} 2.8s ease-in-out infinite;
+ }
+
+ .tear {
+ animation: ${fall} 2.8s linear infinite;
+ }
+ }
+ /* No Blade token for this yet, so the query is inline. */
+ @media (prefers-reduced-motion: reduce) {
+ * {
+ animation: none !important;
+ }
+ }
+`;
+
+const TerribleIcon = ({ size = 28 }: MoodIconProps): React.ReactElement => {
+ const gradientId = React.useId();
+
+ return (
+
+ );
+};
+
+export { TerribleIcon };
diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/index.tsx b/packages/blade/src/components/ChatFeedback/moodIcons/index.tsx
new file mode 100644
index 0000000000..b76a9f1219
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/moodIcons/index.tsx
@@ -0,0 +1,32 @@
+/**
+ * Artwork for the four-point rating scale.
+ *
+ * Not exported from Blade's public entry point. These are fixed-palette faces with gradients and
+ * a coloured thumb outline — they cannot honour a `color` prop, so shipping them through the icon
+ * pipeline would hand consumers an icon that silently ignores half its contract. They stay here,
+ * beside the only component that renders them, until there is a reason to promote them.
+ *
+ * Each is static at rest and animates only while its button is hovered, focused or selected.
+ */
+import React from 'react';
+import { TerribleIcon } from './TerribleIcon';
+import { BadIcon } from './BadIcon';
+import { GoodIcon } from './GoodIcon';
+import { LoveItIcon } from './LoveItIcon';
+import type { ChatFeedbackIcons } from '~components/ChatFeedback/types';
+
+/**
+ * Ready to pass straight to `feedbackIcons`.
+ *
+ * Declared once and imported wherever the scale appears, so changing the artwork is one edit
+ * rather than one per composer.
+ */
+const defaultFeedbackIcons: ChatFeedbackIcons = {
+ 'very-dissatisfied': ,
+ dissatisfied: ,
+ satisfied: ,
+ 'very-satisfied': ,
+};
+
+export { defaultFeedbackIcons, TerribleIcon, BadIcon, GoodIcon, LoveItIcon };
+export type { MoodIconProps } from './types';
diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/types.ts b/packages/blade/src/components/ChatFeedback/moodIcons/types.ts
new file mode 100644
index 0000000000..88a22d971f
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/moodIcons/types.ts
@@ -0,0 +1,11 @@
+type MoodIconProps = {
+ /**
+ * Rendered size, in px unless a CSS length is given.
+ *
+ * Defaults to 28, which is what `ChatFeedbackMoodScale`'s slot gives it. The artwork is drawn
+ * in a 24 unit box, so a face lands at 21px of that.
+ */
+ size?: string | number;
+};
+
+export type { MoodIconProps };
diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/whenActive.ts b/packages/blade/src/components/ChatFeedback/moodIcons/whenActive.ts
new file mode 100644
index 0000000000..b53d72159d
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/moodIcons/whenActive.ts
@@ -0,0 +1,16 @@
+/**
+ * The states a mood glyph animates on.
+ *
+ * The glyph is not the hover target. `ChatFeedbackMoodScale` draws a 20px face inside a 32px
+ * button, so two thirds of the control is padding; keying the animation on the SVG itself would
+ * leave it inert for most of the area a merchant actually points at, and dead entirely for anyone
+ * on a keyboard. Every rule hangs off the ancestor button instead.
+ *
+ * `aria-checked` is included because the button treats hover, focus and selection as one
+ * treatment. A glyph that snapped back to rest the moment the pointer left would undo the only
+ * confirmation a merchant gets that the rating registered.
+ */
+const WHEN_ACTIVE =
+ "button:hover:not(:disabled) &, button:focus-visible &, button[aria-checked='true'] &";
+
+export { WHEN_ACTIVE };
diff --git a/packages/blade/src/components/ChatFeedback/types.ts b/packages/blade/src/components/ChatFeedback/types.ts
new file mode 100644
index 0000000000..e9fa43743c
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/types.ts
@@ -0,0 +1,165 @@
+import type React from 'react';
+import type { DataAnalyticsAttribute, TestID } from '~utils/types';
+import type { StyledPropsBlade } from '~components/Box/styledProps';
+
+/** The four points of the sentiment scale, worst to best. */
+type ChatFeedbackMood = 'very-dissatisfied' | 'dissatisfied' | 'satisfied' | 'very-satisfied';
+
+type ChatFeedbackStep = 'mood' | 'tags' | 'thanks';
+
+type ChatFeedbackMoodConfig = {
+ /** Follow-up question shown once this mood is picked */
+ question: string;
+ /** Quick-select tags offered for this mood */
+ tags: string[];
+ /**
+ * Closing line for this mood.
+ *
+ * A single thank-you across all four points rings false at the unhappy end: someone who has
+ * just said the assistant got it wrong is told their feedback was lovely to receive. The
+ * negative moods acknowledge and commit to something instead.
+ */
+ thanksLabel?: string;
+};
+
+/** One glyph per point of the scale. */
+type ChatFeedbackIcons = Record;
+
+/** The parts of a running flow a host may need to drive. */
+type ChatFeedbackControls = {
+ /** Submits the current mood and tags, as the flow's own tick would. */
+ submit: () => void;
+ /** Replaces the selected tags. */
+ setTags: (tags: string[]) => void;
+};
+
+type ChatFeedbackSubmitPayload = {
+ mood: ChatFeedbackMood;
+ /** Tags the user selected. Empty when they submitted without picking any. */
+ tags: string[];
+ /** Free-text follow-up, present only when the user chose to add more. */
+ comment?: string;
+};
+
+type ChatFeedbackProps = {
+ /**
+ * Question shown in the first step, alongside the mood scale.
+ * @default 'How are we doing so far?'
+ */
+ question?: string;
+
+ /**
+ * Overrides the follow-up question and tags offered for each mood.
+ * Only the moods you provide are overridden — the rest keep their defaults.
+ */
+ moodConfig?: Partial>;
+
+ /**
+ * Callback fired when the user picks a mood, before they submit.
+ * Use this if you want to record the rating even when the flow is abandoned.
+ */
+ onMoodSelect?: ({ mood }: { mood: ChatFeedbackMood }) => void;
+
+ /**
+ * Callback fired whenever the selected tags change.
+ *
+ * Use it to react to a particular tag being picked — a host that wants to collect free text in
+ * its own input rather than this component's, for example.
+ */
+ onTagsChange?: ({ tags }: { tags: string[] }) => void;
+
+ /**
+ * Hides this flow's own submit control.
+ *
+ * Set it when the surrounding surface is showing a submit of its own — a composer collecting
+ * the free-text comment, say. Two ticks on screen doing the same thing is worse than one in the
+ * place the user is already looking.
+ */
+ isSubmitHidden?: boolean;
+
+ /**
+ * Receives a handle on this flow, so a surrounding surface can drive it.
+ *
+ * Set when the host has controls of its own that must act on the same state — a composer that
+ * collects the free-text comment needs to submit the flow from its own send button, and to
+ * release the tag again when the user backs out of typing.
+ */
+ controlsRef?: React.MutableRefObject;
+
+ /**
+ * Callback fired when the user submits their feedback.
+ *
+ * Fires once when tags are submitted, and again with `comment` populated if the user
+ * goes on to add a free-text follow-up.
+ */
+ onSubmit?: (payload: ChatFeedbackSubmitPayload) => void;
+
+ /**
+ * Callback fired when the flow finishes and the component should be taken away.
+ * `ChatFeedback` does not remove itself — hide it in response to this.
+ */
+ onDismiss?: () => void;
+
+ /**
+ * Message shown on the thank-you step, for every mood.
+ *
+ * Leave it unset to use the per-mood copy from `moodConfig`, which differs by sentiment.
+ */
+ thanksLabel?: string;
+
+ /**
+ * Whether the flow dismisses itself shortly after the thank-you step.
+ * When false, `onDismiss` is never fired automatically and you control removal.
+ * @default true
+ */
+ autoDismiss?: boolean;
+
+ /**
+ * Whether the flow fills the width available to it.
+ *
+ * `true` spreads each step edge to edge, pushing the trailing control to the far right.
+ * Use it when the flow sits inside a surface that already has a width — a strip attached
+ * above a composer, for example.
+ *
+ * `false` makes each step only as wide as its own content, so the whole flow can be
+ * dropped into a bar that hugs it. Pair it with `alignSelf="center"` on the wrapper to get
+ * a floating bar that shrinks to fit.
+ *
+ * @default true
+ */
+ isFullWidth?: boolean;
+
+ /**
+ * Artwork for the rating scale, one entry per mood.
+ *
+ * Optional — Blade ships an animated set and uses it when this is omitted, so the component
+ * renders properly on install. Supply your own to use a product's icon set, or plain emoji
+ * characters; each is rendered as given, in a fixed box and hidden from assistive technology,
+ * since the button already carries the mood's name.
+ *
+ * Declare a replacement once in a module and import it, rather than inline — swapping the scale
+ * should be one edit rather than one per surface.
+ *
+ * Hover and selected states are drawn on the button, not the glyph, so they read the same
+ * whether what you pass can be tinted or not.
+ */
+ feedbackIcons?: ChatFeedbackIcons;
+
+ /**
+ * Disables every control in the flow.
+ * @default false
+ */
+ isDisabled?: boolean;
+} & TestID &
+ DataAnalyticsAttribute &
+ StyledPropsBlade;
+
+export type {
+ ChatFeedbackControls,
+ ChatFeedbackIcons,
+ ChatFeedbackProps,
+ ChatFeedbackMood,
+ ChatFeedbackMoodConfig,
+ ChatFeedbackStep,
+ ChatFeedbackSubmitPayload,
+};
diff --git a/packages/blade/src/components/ChatFeedback/useChatFeedback.ts b/packages/blade/src/components/ChatFeedback/useChatFeedback.ts
new file mode 100644
index 0000000000..2622203690
--- /dev/null
+++ b/packages/blade/src/components/ChatFeedback/useChatFeedback.ts
@@ -0,0 +1,130 @@
+import React from 'react';
+import type { ChatFeedbackMood, ChatFeedbackProps, ChatFeedbackStep } from './types';
+import { chatFeedbackDefaultMoodConfig, chatFeedbackThanksDurationMs } from './chatFeedbackTokens';
+import { useTheme } from '~components/BladeProvider';
+
+type UseChatFeedbackProps = Pick<
+ ChatFeedbackProps,
+ 'moodConfig' | 'onMoodSelect' | 'onTagsChange' | 'onSubmit' | 'onDismiss' | 'autoDismiss'
+>;
+
+/**
+ * The whole flow, with no rendering attached.
+ *
+ * Every timer goes through `schedule` so that a step change or unmount cancels anything
+ * still pending — an auto-dismiss that fires after the user has navigated away is the
+ * bug this indirection exists to prevent.
+ */
+const useChatFeedback = ({
+ moodConfig,
+ onMoodSelect,
+ onTagsChange,
+ onSubmit,
+ onDismiss,
+ autoDismiss = true,
+}: UseChatFeedbackProps): {
+ step: ChatFeedbackStep;
+ selectedMood: ChatFeedbackMood | null;
+ selectedTags: string[];
+ question: string;
+ thanksLabel?: string;
+ tags: string[];
+ hasSelectedTags: boolean;
+ selectMood: (mood: ChatFeedbackMood) => void;
+ setSelectedTags: (values: string[]) => void;
+ submitTags: () => void;
+ goBackToMood: () => void;
+} => {
+ const { theme } = useTheme();
+
+ const [step, setStep] = React.useState('mood');
+ const [selectedMood, setSelectedMood] = React.useState(null);
+ const [selectedTags, setSelectedTagsState] = React.useState([]);
+
+ /*
+ * Every route that changes the selection reports it.
+ *
+ * The chip group used to be the only one that did, so the two internal clears — going back to
+ * the moods, and picking a new mood — changed the selection silently. A host mirroring it to
+ * drive its own UI then held tags that no longer existed, and acted on them: a composer handed
+ * the free-text tag stayed in feedback mode after the strip had walked back to the mood step,
+ * with no tag selected and no way out. Reporting from one place makes drift impossible rather
+ * than merely unlikely.
+ */
+ const setSelectedTags = React.useCallback(
+ (values: string[]) => {
+ setSelectedTagsState(values);
+ onTagsChange?.({ tags: values });
+ },
+ [onTagsChange],
+ );
+
+ const timers = React.useRef>>(new Set());
+
+ const schedule = React.useCallback((fn: () => void, ms: number) => {
+ const id = setTimeout(() => {
+ timers.current.delete(id);
+ fn();
+ }, ms);
+ timers.current.add(id);
+ }, []);
+
+ const clearTimers = React.useCallback(() => {
+ timers.current.forEach(clearTimeout);
+ timers.current.clear();
+ }, []);
+
+ React.useEffect(() => clearTimers, [clearTimers]);
+
+ const resolvedConfig = selectedMood
+ ? { ...chatFeedbackDefaultMoodConfig[selectedMood], ...moodConfig?.[selectedMood] }
+ : null;
+
+ const goToThanks = React.useCallback(() => {
+ clearTimers();
+ setStep('thanks');
+ if (!autoDismiss) return;
+ schedule(() => onDismiss?.(), chatFeedbackThanksDurationMs);
+ }, [autoDismiss, clearTimers, onDismiss, schedule]);
+
+ const selectMood = React.useCallback(
+ (mood: ChatFeedbackMood) => {
+ clearTimers();
+ setSelectedMood(mood);
+ setSelectedTags([]);
+ onMoodSelect?.({ mood });
+ // A beat before the follow-up, so the selection is seen before the step changes.
+ schedule(() => setStep('tags'), theme.motion.duration.quick);
+ },
+ [clearTimers, onMoodSelect, schedule, setSelectedTags, theme.motion.duration.quick],
+ );
+
+ const submitTags = React.useCallback(() => {
+ if (!selectedMood) return;
+ onSubmit?.({ mood: selectedMood, tags: selectedTags });
+ goToThanks();
+ }, [goToThanks, onSubmit, selectedMood, selectedTags]);
+
+ const goBackToMood = React.useCallback(() => {
+ clearTimers();
+ setSelectedMood(null);
+ setSelectedTags([]);
+ setStep('mood');
+ }, [clearTimers, setSelectedTags]);
+
+ return {
+ step,
+ selectedMood,
+ selectedTags,
+ question: resolvedConfig?.question ?? '',
+ tags: resolvedConfig?.tags ?? [],
+ thanksLabel: resolvedConfig?.thanksLabel,
+ hasSelectedTags: selectedTags.length > 0,
+ selectMood,
+ setSelectedTags,
+ submitTags,
+ goBackToMood,
+ };
+};
+
+export { useChatFeedback };
diff --git a/packages/blade/src/components/index.ts b/packages/blade/src/components/index.ts
index f0a3597f37..6e74e47acd 100644
--- a/packages/blade/src/components/index.ts
+++ b/packages/blade/src/components/index.ts
@@ -18,6 +18,7 @@ export * from './ButtonGroup';
export * from './Card';
export * from './Carousel';
export * from './Checkbox';
+export * from './ChatFeedback';
export * from './ChatInput';
export * from './ChatMessage';
export * from './Charts';
diff --git a/packages/blade/src/utils/metaAttribute/metaConstants.ts b/packages/blade/src/utils/metaAttribute/metaConstants.ts
index fba22eb302..329c1973e6 100644
--- a/packages/blade/src/utils/metaAttribute/metaConstants.ts
+++ b/packages/blade/src/utils/metaAttribute/metaConstants.ts
@@ -29,6 +29,8 @@ export const MetaConstants = {
BottomNav: 'bottomnav',
BottomNavItem: 'bottomnav-item',
Carousel: 'carousel',
+ ChatComposer: 'chat-composer',
+ ChatFeedback: 'chat-feedback',
ChatInput: 'chat-input',
ChatMessage: 'chat-message',
ChatMessageThumbnailPreview: 'chat-message-thumbnail-preview',
diff --git a/packages/blade/src/utils/storybook/componentStatusData.ts b/packages/blade/src/utils/storybook/componentStatusData.ts
index deab6e3f1b..b5d3aa0102 100644
--- a/packages/blade/src/utils/storybook/componentStatusData.ts
+++ b/packages/blade/src/utils/storybook/componentStatusData.ts
@@ -24,6 +24,22 @@ type ComponentStatusDataType = {
}[];
const componentData: ComponentStatusDataType = [
+ {
+ name: 'ChatFeedback',
+ description:
+ 'Four-point rating flow for conversational surfaces — mood, follow-up tags, and an optional free-text comment.',
+ // Web only for now: the native counterpart throws until it is implemented.
+ platform: 'web',
+ frameworks: {
+ react: {
+ status: 'in-development',
+ storybookLink: 'Components/ChatFeedback',
+ },
+ svelte: {
+ status: 'to-be-decided',
+ },
+ },
+ },
{
name: 'Avatar',
description: 'Avatar component for displaying user profile images or initials.',