Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/quiet-banner-action-cta.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 28 additions & 2 deletions packages/kumo-docs-astro/src/components/demos/BannerDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="w-full space-y-3">
Expand All @@ -183,7 +186,30 @@ export function BannerWithActionsDemo() {
action={
<>
<Banner.Action>Pay now</Banner.Action>
<Banner.Action variant="secondary">Go to billing</Banner.Action>
<Banner.Action variant="ghost">Go to billing</Banner.Action>
</>
}
/>
</div>
);
}

/**
* 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 (
<div className="space-y-3 w-full">
<Banner
icon={<Warning weight="fill" />}
variant="error"
title="Your account is 90 days past due."
description="Pay now to avoid interruption."
action={
<>
<Banner.Action variant="primary">Pay now</Banner.Action>
<Banner.Action variant="ghost">Go to billing</Banner.Action>
</>
}
/>
Expand Down
13 changes: 13 additions & 0 deletions packages/kumo-docs-astro/src/pages/components/banner.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
BannerWithIconDemo,
BannerWithActionDemo,
BannerWithActionsDemo,
BannerActionEmphasisDemo,
BannerCompactDemo,
BannerCompactWithCtaDemo,
BannerCompactWithoutActionDemo,
Expand Down Expand Up @@ -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.

<ComponentExample demo="BannerWithActionDemo">
<BannerWithActionDemo client:visible />
</ComponentExample>

### With multiple actions

Keep the leading CTA on the default treatment and step supporting actions down to `variant="ghost"`.

<ComponentExample demo="BannerWithActionsDemo">
<BannerWithActionsDemo client:visible />
</ComponentExample>

### 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.

<ComponentExample demo="BannerActionEmphasisDemo">
<BannerActionEmphasisDemo client:visible />
</ComponentExample>

### 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.
Expand Down
56 changes: 41 additions & 15 deletions packages/kumo/src/components/banner/banner-action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -55,34 +56,53 @@ export const BannerActionContext = createContext<BannerActionContextValue>({
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 }
> = {
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",
},
};

Expand All @@ -100,10 +120,11 @@ type WithBannerActionVariants<Props> = Props extends ButtonProps
? Omit<Props, "size" | "variant"> & {
/**
* 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;
}
Expand All @@ -115,15 +136,20 @@ export type BannerActionProps = WithBannerActionVariants<ButtonProps>;
* 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
* <Banner.Action onClick={retry}>Retry</Banner.Action>
* <Banner.Action variant="primary" onClick={upgrade}>Upgrade</Banner.Action>
* <Banner.Action variant="ghost" icon={<X />} aria-label="Dismiss" />
* ```
*/
export const BannerAction = forwardRef<HTMLButtonElement, BannerActionProps>(
function BannerAction(
{ variant = "primary", className, style, ...props },
{ variant = "secondary", className, style, ...props },
ref,
) {
const banner = useContext(BannerActionContext);
Expand Down
73 changes: 64 additions & 9 deletions packages/kumo/src/components/banner/banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ describe("Banner", () => {
<Banner
variant="error"
title="Save failed"
action={<Banner.Action data-testid="cta">Retry</Banner.Action>}
action={
<Banner.Action variant="primary" data-testid="cta">
Retry
</Banner.Action>
}
/>,
);

Expand All @@ -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(
<Banner
variant="error"
title="Save failed"
action={<Banner.Action data-testid="cta">Retry</Banner.Action>}
/>,
);

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(
<Banner
Expand All @@ -65,10 +89,35 @@ describe("Banner", () => {

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(
<Banner
variant="error"
title="Save failed"
action={
<Banner.Action disabled data-testid="cta">
Retry
</Banner.Action>
}
/>,
);

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(
<Banner
variant="error"
Expand All @@ -82,13 +131,19 @@ describe("Banner", () => {
);

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"),
Expand Down
3 changes: 2 additions & 1 deletion packages/kumo/src/components/banner/banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down