CodeGenResult> = {
`,
};
},
+ "feedback-buttons": (config) => {
+ const defaults = chordRegistry["feedback-buttons"].defaultConfig;
+ const props = buildPropsString(config, defaults);
+ return {
+ imports: `import { FeedbackButtons } from "@assistant-ui/chords";`,
+ jsx: ``,
+ };
+ },
"reasoning-accordion": (config) => {
const defaults = chordRegistry["reasoning-accordion"].defaultConfig;
const props = buildPropsString(config, defaults);
diff --git a/apps/docs/components/playground/controls-panel.tsx b/apps/docs/components/playground/controls-panel.tsx
index ad0d405..0733856 100644
--- a/apps/docs/components/playground/controls-panel.tsx
+++ b/apps/docs/components/playground/controls-panel.tsx
@@ -13,6 +13,7 @@ import { ThreadEmptyControls } from "./controls/thread-empty-controls";
import { AttachmentControls } from "./controls/attachment-controls";
import { ScrollToBottomControls } from "./controls/scroll-to-bottom-controls";
import { ReasoningAccordionControls } from "./controls/reasoning-accordion-controls";
+import { FeedbackButtonsControls } from "./controls/feedback-buttons-controls";
type ControlsPanelProps = {
chordId: ChordId;
@@ -35,6 +36,7 @@ const controlsMap: Partial<
attachment: AttachmentControls,
"scroll-to-bottom": ScrollToBottomControls,
"reasoning-accordion": ReasoningAccordionControls,
+ "feedback-buttons": FeedbackButtonsControls,
};
export function ControlsPanel({ chordId, config, onChange }: ControlsPanelProps) {
diff --git a/apps/docs/components/playground/controls/feedback-buttons-controls.tsx b/apps/docs/components/playground/controls/feedback-buttons-controls.tsx
new file mode 100644
index 0000000..788295b
--- /dev/null
+++ b/apps/docs/components/playground/controls/feedback-buttons-controls.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import type { ChordConfig } from "@/lib/playground/types";
+import { TextInput, StyleBuilder } from "./shared";
+
+const DEFAULT_BUTTON =
+ "group inline-flex items-center justify-center rounded-md p-1.5 text-zinc-400 transition-colors hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-200 data-[submitted]:text-zinc-900 dark:data-[submitted]:text-zinc-100";
+
+export function FeedbackButtonsControls({
+ config,
+ onChange,
+}: {
+ config: ChordConfig;
+ onChange: (c: ChordConfig) => void;
+}) {
+ return (
+
+ onChange({ ...config, positiveLabel: v || undefined })}
+ placeholder="Good response"
+ />
+ onChange({ ...config, negativeLabel: v || undefined })}
+ placeholder="Bad response"
+ />
+ onChange({ ...config, className: v || undefined })}
+ controls={["bg", "rounded", "p"]}
+ />
+
+ onChange({ ...config, positiveClassName: v || undefined })
+ }
+ controls={["bg", "text"]}
+ />
+
+ onChange({ ...config, negativeClassName: v || undefined })
+ }
+ controls={["bg", "text"]}
+ />
+
+ );
+}
diff --git a/apps/docs/components/playground/preview-panel.tsx b/apps/docs/components/playground/preview-panel.tsx
index 6affc96..aa2d3f2 100644
--- a/apps/docs/components/playground/preview-panel.tsx
+++ b/apps/docs/components/playground/preview-panel.tsx
@@ -18,6 +18,7 @@ import { SuggestionChipsPreview } from "./previews/suggestion-chips-preview";
import { ThreadEmptyPreview } from "./previews/thread-empty-preview";
import { ScrollToBottomPreview } from "./previews/scroll-to-bottom-preview";
import { ReasoningAccordionPreview } from "./previews/reasoning-accordion-preview";
+import { FeedbackButtonsPreview } from "./previews/feedback-buttons-preview";
type PreviewPanelProps = {
chordId: ChordId;
@@ -38,6 +39,7 @@ const previewMap: Record> = {
"thread-empty": ThreadEmptyPreview,
"scroll-to-bottom": ScrollToBottomPreview,
"reasoning-accordion": ReasoningAccordionPreview,
+ "feedback-buttons": FeedbackButtonsPreview,
};
export function PreviewPanel({ chordId, config }: PreviewPanelProps) {
diff --git a/apps/docs/components/playground/previews/feedback-buttons-preview.tsx b/apps/docs/components/playground/previews/feedback-buttons-preview.tsx
new file mode 100644
index 0000000..810dc62
--- /dev/null
+++ b/apps/docs/components/playground/previews/feedback-buttons-preview.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import {
+ AssistantRuntimeProvider,
+ ThreadPrimitive,
+ MessagePrimitive,
+ useLocalRuntime,
+} from "@assistant-ui/react";
+import { FeedbackButtons } from "@assistant-ui/chords";
+import type { ChordConfig } from "@/lib/playground/types";
+import { createPlaygroundAdapter } from "@/lib/playground/mock-runtime";
+import { useRef } from "react";
+
+const UserMessage = () => (
+
+
+
+
+
+);
+
+const AssistantMessage = ({ config }: { config: ChordConfig }) => (
+
+
+ A
+
+
+
(
+
+ {text}
+
+ ),
+ }}
+ />
+
+
+
+
+
+);
+
+export function FeedbackButtonsPreview({
+ config,
+}: {
+ config: ChordConfig;
+}) {
+ const adapter = useRef(createPlaygroundAdapter());
+ const runtime = useLocalRuntime(adapter.current, {
+ adapters: {
+ feedback: {
+ submit: async () => {},
+ },
+ },
+ });
+
+ return (
+
+
+
+
+
+
+ Click to see feedback buttons
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/content/docs/major-chords/feedback-buttons.mdx b/apps/docs/content/docs/major-chords/feedback-buttons.mdx
new file mode 100644
index 0000000..dd5c49f
--- /dev/null
+++ b/apps/docs/content/docs/major-chords/feedback-buttons.mdx
@@ -0,0 +1,76 @@
+---
+title: FeedbackButtons
+description: Thumbs up / thumbs down feedback buttons with automatic state management.
+---
+
+## Overview
+
+`FeedbackButtons` provides thumbs up and thumbs down buttons for assistant messages. It wraps `ActionBarPrimitive.FeedbackPositive` and `ActionBarPrimitive.FeedbackNegative`, which handle `submitFeedback` calls and highlight the selected button automatically via `data-submitted`.
+
+## Demo
+
+
+
+## When to use
+
+- Collect user feedback on assistant responses
+- Show which response was rated positively or negatively
+- Build feedback loops for model improvement
+
+## Features
+
+- **State-aware**: Automatically highlights the submitted feedback button via `data-submitted`
+- **Zero wiring**: `submitFeedback` is called by the underlying primitives — no callbacks needed
+- **Customizable**: Override classes, labels, and icons
+- **Accessible**: Proper `aria-label` on each button
+
+## Props
+
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `className` | string | — | Root container class |
+| `positiveClassName` | string | — | Thumbs up button class |
+| `negativeClassName` | string | — | Thumbs down button class |
+| `iconClassName` | string | — | Icon class for both icons |
+| `positiveLabel` | string | `"Good response"` | Aria-label for thumbs up |
+| `negativeLabel` | string | `"Bad response"` | Aria-label for thumbs down |
+| `renderPositiveIcon` | `() => ReactNode` | — | Custom thumbs up icon |
+| `renderNegativeIcon` | `() => ReactNode` | — | Custom thumbs down icon |
+
+## Basic Usage
+
+```tsx
+import { FeedbackButtons } from "@assistant-ui/chords";
+
+// Inside an assistant message
+
+
+
+
+```
+
+## With MessageActionBar
+
+```tsx
+
+
+
+
+```
+
+## Custom Labels
+
+```tsx
+
+```
+
+## Underlying Primitives
+
+`FeedbackButtons` wraps:
+- `ActionBarPrimitive.FeedbackPositive` — calls `submitFeedback({ type: "positive" })`, sets `data-submitted` when positive feedback is active
+- `ActionBarPrimitive.FeedbackNegative` — calls `submitFeedback({ type: "negative" })`, sets `data-submitted` when negative feedback is active
+
+The `data-submitted` attribute is used for styling the active/highlighted state.
diff --git a/apps/docs/content/docs/major-chords/meta.json b/apps/docs/content/docs/major-chords/meta.json
index 7a7f837..afbc359 100644
--- a/apps/docs/content/docs/major-chords/meta.json
+++ b/apps/docs/content/docs/major-chords/meta.json
@@ -9,6 +9,7 @@
"follow-up-suggestions",
"attachment",
"tool-call-renderer",
- "reasoning-accordion"
+ "reasoning-accordion",
+ "feedback-buttons"
]
}
diff --git a/apps/docs/lib/playground/chord-registry.ts b/apps/docs/lib/playground/chord-registry.ts
index dea5fc3..be9db03 100644
--- a/apps/docs/lib/playground/chord-registry.ts
+++ b/apps/docs/lib/playground/chord-registry.ts
@@ -103,6 +103,16 @@ export const chordRegistry: Record = {
icon: "U",
},
},
+ "feedback-buttons": {
+ id: "feedback-buttons",
+ name: "FeedbackButtons",
+ category: "major",
+ description: "Thumbs up/down feedback buttons with automatic state management",
+ defaultConfig: {
+ positiveLabel: "Good response",
+ negativeLabel: "Bad response",
+ },
+ },
"reasoning-accordion": {
id: "reasoning-accordion",
name: "ReasoningAccordion",
diff --git a/apps/docs/lib/playground/types.ts b/apps/docs/lib/playground/types.ts
index d8235e7..570a15e 100644
--- a/apps/docs/lib/playground/types.ts
+++ b/apps/docs/lib/playground/types.ts
@@ -12,6 +12,7 @@ export const CHORD_IDS = [
"thread-empty",
"scroll-to-bottom",
"reasoning-accordion",
+ "feedback-buttons",
] as const;
export type ChordId = (typeof CHORD_IDS)[number];
diff --git a/apps/docs/mdx-components.tsx b/apps/docs/mdx-components.tsx
index ecda148..78820aa 100644
--- a/apps/docs/mdx-components.tsx
+++ b/apps/docs/mdx-components.tsx
@@ -27,6 +27,7 @@ import { FollowUpSuggestionsDemo } from "@/components/demo/follow-up-suggestions
import { AttachmentDemo } from "@/components/demo/attachment-demo";
import { ToolCallRendererDemo } from "@/components/demo/tool-call-renderer-demo";
import { ReasoningAccordionDemo } from "@/components/demo/reasoning-accordion-demo";
+import { FeedbackButtonsDemo } from "@/components/demo/feedback-buttons-demo";
type Components = Record>;
@@ -59,6 +60,7 @@ export function getMDXComponents(components?: Components): Components {
AttachmentDemo,
ToolCallRendererDemo,
ReasoningAccordionDemo,
+ FeedbackButtonsDemo,
blockquote: (props: any) => {props.children},
...components,
};
diff --git a/apps/example/app/components/Assistant.tsx b/apps/example/app/components/Assistant.tsx
index 426a44b..32b7e9a 100644
--- a/apps/example/app/components/Assistant.tsx
+++ b/apps/example/app/components/Assistant.tsx
@@ -15,6 +15,7 @@ import {
ComposerAddAttachment,
ComposerAttachments,
EditComposer,
+ FeedbackButtons,
FollowUpSuggestions,
MessageActionBar,
MessageAttachments,
@@ -41,6 +42,11 @@ export const Assistant: FC = () => {
];
},
},
+ feedback: {
+ submit: async ({ type }) => {
+ console.log(`Feedback submitted: ${type}`);
+ },
+ },
},
});
@@ -155,6 +161,7 @@ const AssistantMessage: FC = () => {
+
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 0d19791..cb259f2 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -11,3 +11,4 @@ export * from "./primitives/follow-up-suggestions";
export * from "./primitives/attachment";
export * from "./primitives/tool-call-renderer";
export * from "./primitives/reasoning-accordion";
+export * from "./primitives/feedback-buttons";
diff --git a/packages/core/src/primitives/feedback-buttons/FeedbackButtons.tsx b/packages/core/src/primitives/feedback-buttons/FeedbackButtons.tsx
new file mode 100644
index 0000000..5663a71
--- /dev/null
+++ b/packages/core/src/primitives/feedback-buttons/FeedbackButtons.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import { ActionBarPrimitive } from "@assistant-ui/react";
+import {
+ DEFAULT_ROOT_CLASSNAME,
+ DEFAULT_BUTTON_CLASSNAME,
+ DEFAULT_ICON_CLASSNAME,
+} from "./defaults";
+
+export type FeedbackButtonsProps = {
+ /** Root container class. */
+ className?: string;
+ /** Thumbs up button class. */
+ positiveClassName?: string;
+ /** Thumbs down button class. */
+ negativeClassName?: string;
+ /** Icon class applied to both icons. */
+ iconClassName?: string;
+ /** Aria-label for thumbs up. @default "Good response" */
+ positiveLabel?: string;
+ /** Aria-label for thumbs down. @default "Bad response" */
+ negativeLabel?: string;
+ /** Custom thumbs up icon renderer. */
+ renderPositiveIcon?: () => React.ReactNode;
+ /** Custom thumbs down icon renderer. */
+ renderNegativeIcon?: () => React.ReactNode;
+};
+
+/**
+ * Thumbs up / thumbs down feedback buttons for assistant messages.
+ *
+ * Wraps `ActionBarPrimitive.FeedbackPositive` and `ActionBarPrimitive.FeedbackNegative`.
+ * The primitives handle `submitFeedback` calls and set `data-submitted` automatically.
+ *
+ * Must be rendered inside a message context (e.g. within `MessagePrimitive.Root`
+ * or alongside `ActionBarPrimitive.Root`).
+ *
+ * ```tsx
+ *
+ * ```
+ */
+export function FeedbackButtons({
+ className,
+ positiveClassName,
+ negativeClassName,
+ iconClassName,
+ positiveLabel = "Good response",
+ negativeLabel = "Bad response",
+ renderPositiveIcon,
+ renderNegativeIcon,
+}: FeedbackButtonsProps) {
+ return (
+
+
+ {renderPositiveIcon ? (
+ renderPositiveIcon()
+ ) : (
+
+ )}
+
+
+ {renderNegativeIcon ? (
+ renderNegativeIcon()
+ ) : (
+
+ )}
+
+
+ );
+}
+
+FeedbackButtons.displayName = "FeedbackButtons";
+
+// --- Inline SVG icons (no lucide dependency) ---
+// Each renders an outline icon (hidden when submitted) and a filled icon
+// (hidden when not submitted). The parent button's `data-submitted` attribute
+// controls visibility via CSS: `group-data-[submitted]` shows/hides.
+
+const OUTLINE_HIDE = "block group-data-[submitted]:hidden";
+const FILLED_SHOW = "hidden group-data-[submitted]:block";
+
+function ThumbsUpIcons({ className }: { className?: string }) {
+ return (
+
+ {/* Outline (default) */}
+
+ {/* Filled (submitted) */}
+
+
+ );
+}
+
+function ThumbsDownIcons({ className }: { className?: string }) {
+ return (
+
+ {/* Outline (default) */}
+
+ {/* Filled (submitted) */}
+
+
+ );
+}
diff --git a/packages/core/src/primitives/feedback-buttons/__tests__/FeedbackButtons.test.tsx b/packages/core/src/primitives/feedback-buttons/__tests__/FeedbackButtons.test.tsx
new file mode 100644
index 0000000..25cd233
--- /dev/null
+++ b/packages/core/src/primitives/feedback-buttons/__tests__/FeedbackButtons.test.tsx
@@ -0,0 +1,138 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+
+vi.mock("@assistant-ui/react", () => ({
+ ActionBarPrimitive: {
+ FeedbackPositive: ({ children, className, "aria-label": ariaLabel, ...rest }: any) => (
+
+ ),
+ FeedbackNegative: ({ children, className, "aria-label": ariaLabel, ...rest }: any) => (
+
+ ),
+ },
+}));
+
+import { FeedbackButtons } from "../FeedbackButtons";
+import {
+ DEFAULT_ROOT_CLASSNAME,
+ DEFAULT_BUTTON_CLASSNAME,
+ DEFAULT_ICON_CLASSNAME,
+} from "../defaults";
+
+describe("FeedbackButtons", () => {
+ it("renders both buttons", () => {
+ render();
+ expect(screen.getByTestId("feedback-positive")).toBeInTheDocument();
+ expect(screen.getByTestId("feedback-negative")).toBeInTheDocument();
+ });
+
+ it("uses default root class", () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass(...DEFAULT_ROOT_CLASSNAME.split(" "));
+ });
+
+ it("uses default button class on both buttons", () => {
+ render();
+ expect(screen.getByTestId("feedback-positive").className).toBe(
+ DEFAULT_BUTTON_CLASSNAME,
+ );
+ expect(screen.getByTestId("feedback-negative").className).toBe(
+ DEFAULT_BUTTON_CLASSNAME,
+ );
+ });
+
+ it("uses custom className", () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass("custom-root");
+ });
+
+ it("uses custom positiveClassName", () => {
+ render();
+ expect(screen.getByTestId("feedback-positive").className).toBe("custom-pos");
+ });
+
+ it("uses custom negativeClassName", () => {
+ render();
+ expect(screen.getByTestId("feedback-negative").className).toBe("custom-neg");
+ });
+
+ it("renders default aria-labels", () => {
+ render();
+ expect(screen.getByTestId("feedback-positive")).toHaveAttribute(
+ "aria-label",
+ "Good response",
+ );
+ expect(screen.getByTestId("feedback-negative")).toHaveAttribute(
+ "aria-label",
+ "Bad response",
+ );
+ });
+
+ it("uses custom labels", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("feedback-positive")).toHaveAttribute(
+ "aria-label",
+ "Helpful",
+ );
+ expect(screen.getByTestId("feedback-negative")).toHaveAttribute(
+ "aria-label",
+ "Not helpful",
+ );
+ });
+
+ it("renders default SVG icons", () => {
+ render();
+ const posBtn = screen.getByTestId("feedback-positive");
+ const negBtn = screen.getByTestId("feedback-negative");
+ expect(posBtn.querySelector("svg")).toBeInTheDocument();
+ expect(negBtn.querySelector("svg")).toBeInTheDocument();
+ });
+
+ it("applies default icon class to icon wrapper", () => {
+ render();
+ const posWrapper = screen.getByTestId("feedback-positive").querySelector("span");
+ const negWrapper = screen.getByTestId("feedback-negative").querySelector("span");
+ expect(posWrapper).toHaveClass(...DEFAULT_ICON_CLASSNAME.split(" "));
+ expect(negWrapper).toHaveClass(...DEFAULT_ICON_CLASSNAME.split(" "));
+ });
+
+ it("applies custom iconClassName to icon wrapper", () => {
+ render();
+ const posWrapper = screen.getByTestId("feedback-positive").querySelector("span");
+ expect(posWrapper).toHaveClass("size-6");
+ });
+
+ it("renders custom positive icon", () => {
+ render(
+ +}
+ />,
+ );
+ expect(screen.getByTestId("custom-pos-icon")).toBeInTheDocument();
+ });
+
+ it("renders custom negative icon", () => {
+ render(
+ -}
+ />,
+ );
+ expect(screen.getByTestId("custom-neg-icon")).toBeInTheDocument();
+ });
+});
diff --git a/packages/core/src/primitives/feedback-buttons/defaults.ts b/packages/core/src/primitives/feedback-buttons/defaults.ts
new file mode 100644
index 0000000..b2eaf66
--- /dev/null
+++ b/packages/core/src/primitives/feedback-buttons/defaults.ts
@@ -0,0 +1,7 @@
+export const DEFAULT_ROOT_CLASSNAME =
+ "flex items-center gap-1";
+
+export const DEFAULT_BUTTON_CLASSNAME =
+ "group inline-flex items-center justify-center rounded-md p-1.5 text-zinc-400 transition-colors hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-200 data-[submitted]:text-zinc-900 dark:data-[submitted]:text-zinc-100";
+
+export const DEFAULT_ICON_CLASSNAME = "size-4";
diff --git a/packages/core/src/primitives/feedback-buttons/index.ts b/packages/core/src/primitives/feedback-buttons/index.ts
new file mode 100644
index 0000000..00c7c49
--- /dev/null
+++ b/packages/core/src/primitives/feedback-buttons/index.ts
@@ -0,0 +1 @@
+export { FeedbackButtons, type FeedbackButtonsProps } from "./FeedbackButtons";