diff --git a/.changeset/quiet-banner-action-cta.md b/.changeset/quiet-banner-action-cta.md
new file mode 100644
index 0000000000..4588ddda3e
--- /dev/null
+++ b/.changeset/quiet-banner-action-cta.md
@@ -0,0 +1,9 @@
+---
+"@cloudflare/kumo": patch
+---
+
+Softens the `Banner.Action` CTA so it no longer overpowers the banner's message.
+
+- `Banner.Action` now defaults to `variant="secondary"` instead of `variant="primary"`. The default treatment is an accent-tinted fill with an accent hairline, which reads as a button without competing with page-level CTAs. Opt back in with `variant="primary"` when the banner's action is the single most important one on the page.
+- Brightened the `secondary` and `ghost` accent colors. Labels and icons now use the banner's accent text token directly rather than inheriting the container color, which previously picked up the neutral banner's dimmed `text-kumo-default/70` and made the CTA look muddy.
+- Gated all `Banner.Action` hover colors behind `not-disabled:`, so hover styling stays inert on disabled and loading actions.
diff --git a/packages/kumo-docs-astro/src/components/demos/BannerDemo.tsx b/packages/kumo-docs-astro/src/components/demos/BannerDemo.tsx
index ab14ae6ba8..5ad8b2253d 100644
--- a/packages/kumo-docs-astro/src/components/demos/BannerDemo.tsx
+++ b/packages/kumo-docs-astro/src/components/demos/BannerDemo.tsx
@@ -171,7 +171,10 @@ export function BannerWithActionDemo() {
);
}
-/** Banner with multiple action buttons. */
+/**
+ * Banner with multiple action buttons. The leading CTA keeps the default quiet
+ * accent chip; supporting actions step down to `variant="ghost"`.
+ */
export function BannerWithActionsDemo() {
return (
@@ -183,7 +186,30 @@ export function BannerWithActionsDemo() {
action={
<>
Pay now
- Go to billing
+ Go to billing
+ >
+ }
+ />
+
+ );
+}
+
+/**
+ * Escalate to `variant="primary"` only when the banner's CTA is the single most
+ * important action on the page — a filled accent button competes with page-level CTAs.
+ */
+export function BannerActionEmphasisDemo() {
+ return (
+
+
}
+ variant="error"
+ title="Your account is 90 days past due."
+ description="Pay now to avoid interruption."
+ action={
+ <>
+
Pay now
+
Go to billing
>
}
/>
diff --git a/packages/kumo-docs-astro/src/pages/components/banner.mdx b/packages/kumo-docs-astro/src/pages/components/banner.mdx
index 2da2ba5b35..aced78e16b 100644
--- a/packages/kumo-docs-astro/src/pages/components/banner.mdx
+++ b/packages/kumo-docs-astro/src/pages/components/banner.mdx
@@ -18,6 +18,7 @@ import {
BannerWithIconDemo,
BannerWithActionDemo,
BannerWithActionsDemo,
+ BannerActionEmphasisDemo,
BannerCompactDemo,
BannerCompactWithCtaDemo,
BannerCompactWithoutActionDemo,
@@ -113,16 +114,28 @@ export default function Example() {
### With action
+`Banner.Action` picks up the banner's accent color automatically. It defaults to `variant="secondary"` — an accent-tinted fill with an accent hairline — so the CTA stays readable without shouting over the message.
+
### With multiple actions
+Keep the leading CTA on the default treatment and step supporting actions down to `variant="ghost"`.
+
+### High-emphasis action
+
+Escalate to `variant="primary"` only when the banner's CTA is the single most important action on the page — a filled accent button competes with page-level CTAs.
+
+
+
+
+
### Compact size
Use `size="sm"` in dialogs and other space-constrained contexts. Here, a banner should support the surrounding experience without competing with its primary action, so prefer a Kumo `Link` that renders inline with the description. When greater emphasis is warranted, use `Banner.Action`, which renders at the trailing end.
diff --git a/packages/kumo/src/components/banner/banner-action.tsx b/packages/kumo/src/components/banner/banner-action.tsx
index 4df98f62ca..b53704048b 100644
--- a/packages/kumo/src/components/banner/banner-action.tsx
+++ b/packages/kumo/src/components/banner/banner-action.tsx
@@ -15,8 +15,9 @@ import type { KumoBannerVariant } from "./banner";
/**
* Visual variant for a `Banner.Action`, aligned with `Button`'s `variant` naming.
- * - `"primary"` — filled accent gradient for the main action.
- * - `"secondary"` — transparent with an accent-hued outline (same hue as the banner).
+ * - `"secondary"` — accent-tinted fill with an accent hairline (default).
+ * - `"primary"` — filled accent gradient; reserve for banners whose CTA is the
+ * single most important action on the page, as it competes with page-level CTAs.
* - `"ghost"` — text-only accent action with a faint accent-tinted hover.
*/
export type BannerActionVariant = Extract<
@@ -55,7 +56,22 @@ export const BannerActionContext = createContext
({
size: "sm",
});
-/** Per-banner-variant colors passed to the underlying `Button`. */
+/**
+ * Per-banner-variant colors passed to the underlying `Button`.
+ *
+ * `secondary` is the default CTA treatment: an accent-tinted fill over the banner's
+ * already-tinted surface plus an accent hairline, so the action reads as a button
+ * without shouting over the message. Hover deepens both the fill and the hairline.
+ *
+ * The label uses the explicit `text-kumo-*` token rather than `text-inherit`. For the
+ * accent banners this matches what they already set on the container, but the neutral
+ * `secondary` banner dims its body text to `text-kumo-default/70`, and inheriting that
+ * made the chip read muddy against the tinted fill.
+ *
+ * Hover colors are prefixed with `not-disabled:` so they match the modifier set on
+ * `Button`'s own `not-disabled:hover:*` outline defaults — that lets tailwind-merge
+ * dedupe them and keeps hover inert on a disabled or loading action.
+ */
const BANNER_ACTION_ACCENTS: Record<
KumoBannerVariant,
{ accent: string; secondary: string; ghost: string }
@@ -63,26 +79,30 @@ const BANNER_ACTION_ACCENTS: Record<
default: {
accent: "var(--color-kumo-info)",
secondary:
- "text-inherit ring-kumo-info/50 fill-kumo-info hover:!text-inherit hover:!ring-kumo-info/50 hover:bg-kumo-info/10",
- ghost: "text-inherit fill-kumo-info hover:bg-kumo-info/10",
+ "bg-kumo-info/12 text-kumo-info ring-kumo-info/40 fill-kumo-info not-disabled:hover:text-kumo-info not-disabled:hover:ring-kumo-info/60 not-disabled:hover:bg-kumo-info/20",
+ ghost:
+ "text-kumo-info fill-kumo-info not-disabled:hover:bg-kumo-info/12 not-disabled:hover:text-kumo-info",
},
alert: {
accent: "var(--color-kumo-warning)",
secondary:
- "text-inherit ring-kumo-warning/50 fill-kumo-warning hover:!text-inherit hover:!ring-kumo-warning/50 hover:bg-kumo-warning/10",
- ghost: "text-inherit fill-kumo-warning hover:bg-kumo-warning/10",
+ "bg-kumo-warning/12 text-kumo-warning ring-kumo-warning/40 fill-kumo-warning not-disabled:hover:text-kumo-warning not-disabled:hover:ring-kumo-warning/60 not-disabled:hover:bg-kumo-warning/20",
+ ghost:
+ "text-kumo-warning fill-kumo-warning not-disabled:hover:bg-kumo-warning/12 not-disabled:hover:text-kumo-warning",
},
error: {
accent: "var(--color-kumo-danger)",
secondary:
- "text-inherit ring-kumo-danger/50 fill-kumo-danger hover:!text-inherit hover:!ring-kumo-danger/50 hover:bg-kumo-danger/10",
- ghost: "text-inherit fill-kumo-danger hover:bg-kumo-danger/10",
+ "bg-kumo-danger/12 text-kumo-danger ring-kumo-danger/40 fill-kumo-danger not-disabled:hover:text-kumo-danger not-disabled:hover:ring-kumo-danger/60 not-disabled:hover:bg-kumo-danger/20",
+ ghost:
+ "text-kumo-danger fill-kumo-danger not-disabled:hover:bg-kumo-danger/12 not-disabled:hover:text-kumo-danger",
},
secondary: {
accent: "var(--color-neutral-700, oklch(37.1% 0 0))",
secondary:
- "text-inherit ring-kumo-focus/20 fill-kumo-subtle hover:!text-inherit hover:!ring-kumo-focus/20 hover:bg-kumo-contrast/10",
- ghost: "text-inherit fill-kumo-subtle hover:bg-kumo-contrast/10",
+ "bg-kumo-contrast/8 text-kumo-default ring-kumo-focus/30 fill-kumo-subtle not-disabled:hover:text-kumo-strong not-disabled:hover:ring-kumo-focus/45 not-disabled:hover:bg-kumo-contrast/12",
+ ghost:
+ "text-kumo-default fill-kumo-subtle not-disabled:hover:bg-kumo-contrast/10 not-disabled:hover:text-kumo-strong",
},
};
@@ -100,10 +120,11 @@ type WithBannerActionVariants = Props extends ButtonProps
? Omit & {
/**
* Visual variant of the CTA, aligned with `Button`'s `variant` naming.
- * - `"primary"` — filled accent gradient for the main action (default).
- * - `"secondary"` — transparent with an accent-hued outline matching the banner.
+ * - `"secondary"` — accent-tinted fill with an accent hairline (default).
+ * - `"primary"` — filled accent gradient. Reserve for banners whose CTA is the
+ * single most important action on the page; it competes with page-level CTAs.
* - `"ghost"` — text-only accent action with a faint accent-tinted hover.
- * @default "primary"
+ * @default "secondary"
*/
variant?: BannerActionVariant;
}
@@ -115,15 +136,20 @@ export type BannerActionProps = WithBannerActionVariants;
* A banner CTA built on Kumo's `Button`. It inherits Button's sizing, interaction,
* loading, and accessibility behavior while supplying banner-specific accent styles.
*
+ * Defaults to the quiet `"secondary"` treatment so the CTA stays subordinate to the
+ * banner's message; opt into `variant="primary"` when the action is the page's most
+ * important one.
+ *
* @example
* ```tsx
* Retry
+ * Upgrade
* } aria-label="Dismiss" />
* ```
*/
export const BannerAction = forwardRef(
function BannerAction(
- { variant = "primary", className, style, ...props },
+ { variant = "secondary", className, style, ...props },
ref,
) {
const banner = useContext(BannerActionContext);
diff --git a/packages/kumo/src/components/banner/banner.test.tsx b/packages/kumo/src/components/banner/banner.test.tsx
index 0faa017a28..a9771a72c8 100644
--- a/packages/kumo/src/components/banner/banner.test.tsx
+++ b/packages/kumo/src/components/banner/banner.test.tsx
@@ -32,7 +32,11 @@ describe("Banner", () => {
Retry}
+ action={
+
+ Retry
+
+ }
/>,
);
@@ -50,6 +54,26 @@ describe("Banner", () => {
expect(cta.parentElement?.className).toContain("gap-2");
});
+ it("defaults Banner.Action to the quiet accent-tinted secondary treatment", () => {
+ render(
+ Retry}
+ />,
+ );
+
+ const cta = screen.getByTestId("cta");
+ expect(cta.className).toContain("bg-kumo-danger/12");
+ expect(cta.className).toContain("ring-kumo-danger/40");
+ expect(cta.className).toContain("text-kumo-danger");
+ // Must not fall back to the loud filled primary CTA.
+ expect(cta.className).not.toContain("text-white");
+ expect(
+ cta.style.getPropertyValue("--kumo-button-emphasis-gradient-end"),
+ ).toBe("");
+ });
+
it("styles a ghost Banner.Action with accent text and a tinted hover", () => {
render(
{
const cta = screen.getByTestId("cta");
expect(cta.className).toContain("fill-kumo-warning");
- expect(cta.className).toContain("hover:bg-kumo-warning/10");
+ expect(cta.className).toContain("text-kumo-warning");
+ expect(cta.className).toContain("not-disabled:hover:bg-kumo-warning/12");
+ });
+
+ it("keeps a disabled Banner.Action's hover treatment inert and dims it", () => {
+ render(
+
+ Retry
+
+ }
+ />,
+ );
+
+ const cta = screen.getByTestId("cta");
+ expect((cta as HTMLButtonElement).disabled).toBe(true);
+ // Button dims the whole chip, so the accent fill/ring recede with it.
+ expect(cta.className).toContain("opacity-50");
+ expect(cta.className).toContain("cursor-not-allowed");
+ // Hover colors are gated behind not-disabled: so they cannot fire here, and
+ // none of them carry `!important` that would beat disabled:text-kumo-subtle.
+ expect(cta.className).not.toMatch(/(^|\s)hover:/);
+ expect(cta.className).not.toContain("!");
});
- it("styles a secondary Banner.Action as an accent-hued outline", () => {
+ it("styles a secondary Banner.Action as an accent-tinted chip", () => {
render(
{
);
const cta = screen.getByTestId("cta");
- // Transparent bg + accent-hued ring in the same hue as the error banner accent.
- expect(cta.className).toContain("ring-kumo-danger/50");
- expect(cta.className).toContain("hover:bg-kumo-danger/10");
- expect(cta.className).toContain("hover:!ring-kumo-danger/50");
- expect(cta.className).toContain("hover:!text-inherit");
+ // Accent fill + accent hairline in the same hue as the error banner accent.
+ expect(cta.className).toContain("bg-kumo-danger/12");
+ expect(cta.className).toContain("ring-kumo-danger/40");
+ expect(cta.className).toContain("not-disabled:hover:bg-kumo-danger/20");
+ expect(cta.className).toContain("not-disabled:hover:ring-kumo-danger/60");
+ expect(cta.className).toContain("not-disabled:hover:text-kumo-danger");
+ // Button's outline hover defaults are deduped away by tailwind-merge.
+ expect(cta.className).not.toContain("text-kumo-strong");
+ expect(cta.className).not.toContain("ring-kumo-focus/25");
+ // Explicit bright text token, not the banner's dimmed inherited color.
+ expect(cta.className).toContain("text-kumo-danger");
+ expect(cta.className).not.toContain("text-inherit");
// Not the filled primary CTA.
- expect(cta.className).toContain("bg-transparent");
expect(cta.className).not.toContain("text-white");
expect(
cta.style.getPropertyValue("--kumo-button-emphasis-gradient-end"),
diff --git a/packages/kumo/src/components/banner/banner.tsx b/packages/kumo/src/components/banner/banner.tsx
index 4e956c7c4f..9343002b26 100644
--- a/packages/kumo/src/components/banner/banner.tsx
+++ b/packages/kumo/src/components/banner/banner.tsx
@@ -345,7 +345,8 @@ BannerRoot.displayName = "Banner";
* Full-width message bar with an optional trailing CTA slot.
*
* `Banner.Action` is an accent-aware CTA button
- * (`variant="primary" | "secondary" | "ghost"`).
+ * (`variant="secondary" | "primary" | "ghost"`, defaulting to the quiet
+ * `"secondary"` treatment).
*/
export const Banner = Object.assign(BannerRoot, {
Action: BannerAction,