diff --git a/packages/react/src/components/F0Button/__stories__/F0Button.mdx b/packages/react/src/components/F0Button/__stories__/F0Button.mdx
index 8d5c93efe0..438cd759c4 100644
--- a/packages/react/src/components/F0Button/__stories__/F0Button.mdx
+++ b/packages/react/src/components/F0Button/__stories__/F0Button.mdx
@@ -141,6 +141,12 @@ Icons reinforce the label meaning. Use leading icons for the most common case, t
+### Counter
+
+
+
+Counters surface a quantity tied to the action — items to review, pending approvals — right on the button, so the number and its call to action stay together. Set `counterValue` and a pill appears after the label, sized to the button and tucked flush against its trailing edge. It adapts to the surface it sits on: a dark pill on the primary field, and on critical only while hovered, where the field darkens enough to wash a light pill out; elsewhere it stays a neutral grey. A count of zero shows nothing, so the same button reads cleanly whether or not there is anything to count.
+
### States
diff --git a/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx b/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx
index 6e4b982289..5f6fc11e60 100644
--- a/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx
+++ b/packages/react/src/components/F0Button/__stories__/F0Button.stories.tsx
@@ -557,3 +557,28 @@ export const WithDataTestId: Story = {
await expect(canvas.getByTestId("my-test-button")).toBeInTheDocument()
},
}
+
+export const Counter: Story = {
+ tags: ["no-sidebar"],
+ render: (args) => (
+
+
+
+
+
+
+
+
+
+ ),
+}
diff --git a/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx b/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx
index b89aaf17db..f13980f387 100644
--- a/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx
+++ b/packages/react/src/components/F0Button/__tests__/F0Button.test.tsx
@@ -97,4 +97,61 @@ describe("F0Button", () => {
expect(button).not.toBeDisabled()
expect(onError).toHaveBeenCalled()
})
+
+ describe("counter", () => {
+ it("renders the counter value", () => {
+ render()
+ expect(screen.getByText("3")).toBeInTheDocument()
+ })
+
+ it("tightens the button's right padding when a counter is present", () => {
+ render()
+ expect(screen.getByRole("button").className).toContain("[&_.main]:!pr-2")
+ })
+
+ it("keeps padding symmetric when there is no counter", () => {
+ render()
+ expect(screen.getByRole("button").className).not.toContain("!pr-2")
+ })
+
+ it("shows nothing when the count is 0 — no pill, no padding change", () => {
+ render()
+ expect(screen.queryByText("0")).not.toBeInTheDocument()
+ expect(screen.getByRole("button").className).not.toContain("!pr-2")
+ })
+
+ it("uses the smaller counter on sm and the larger one on md/lg", () => {
+ const { container: sm } = render(
+
+ )
+ const { container: lg } = render(
+
+ )
+ const counterClass = (c: HTMLElement) =>
+ Array.from(c.querySelectorAll("div")).find((d) =>
+ d.className.includes("rounded")
+ )?.className ?? ""
+ expect(counterClass(sm)).toContain("min-w-4")
+ expect(counterClass(lg)).toContain("min-w-5")
+ })
+
+ const counterWrapper = () => screen.getByText("3").closest("span")
+
+ it("gives the primary counter a dark pill", () => {
+ render()
+ expect(counterWrapper()?.className).toContain("dark")
+ })
+
+ it("keeps the counter neutral on promote", () => {
+ render()
+ expect(counterWrapper()?.className).not.toContain("dark")
+ })
+
+ it("darkens the critical counter only on hover", async () => {
+ render()
+ expect(counterWrapper()?.className).not.toContain("dark")
+ await userEvent.hover(screen.getByRole("button"))
+ expect(counterWrapper()?.className).toContain("dark")
+ })
+ })
})
diff --git a/packages/react/src/components/F0Button/internal-types.ts b/packages/react/src/components/F0Button/internal-types.ts
index 526818c552..078706f847 100644
--- a/packages/react/src/components/F0Button/internal-types.ts
+++ b/packages/react/src/components/F0Button/internal-types.ts
@@ -50,7 +50,7 @@ export type ButtonInternalProps = Pick<
*/
variant?: ActionButtonVariant
/**
- * The filters'counter value to display.
+ * A count shown in a neutral counter to the right of the label.
*/
counterValue?: number
/**
diff --git a/packages/react/src/components/F0Button/internal.tsx b/packages/react/src/components/F0Button/internal.tsx
index ed4a5a266a..ff28ac06f6 100644
--- a/packages/react/src/components/F0Button/internal.tsx
+++ b/packages/react/src/components/F0Button/internal.tsx
@@ -76,6 +76,15 @@ const ButtonInternal = forwardRef<
const shouldHideLabel = hideLabel || emoji
const buttonLabel = (label ?? "").toString()
+ // A count of 0 (or no value) shows nothing: no pill, no padding change.
+ const hasCounter = counterValue !== undefined && counterValue > 0
+ // The counter matches the button height — 20px on md/lg, 16px on sm.
+ const counterSize = size === "sm" ? "sm" : "md"
+ // A dark counter pill: always on the primary (default) solid field, and on
+ // critical only while hovered, where the field darkens enough to wash out the
+ // light pill. Other variants (including promote) keep the neutral counter.
+ const counterIsDark =
+ variant === "default" || (variant === "critical" && isHovered)
const buttonFontSize = fontSize ?? size
const iconNode = icon ? (
@@ -123,6 +132,17 @@ const ButtonInternal = forwardRef<
className={cn(
"max-w-full",
block && "w-full",
+ // A trailing counter has its own bordered edge, so the button's right
+ // padding tightens 4px (Figma "ButtonCounter"); the left is unchanged.
+ // Important because the override and the size variant's `px` both target
+ // `.main` as arbitrary variants, which tailwind-merge leaves unmerged —
+ // so the cascade, not class order, has to decide, and `!` guarantees it.
+ hasCounter &&
+ {
+ sm: "[&_.main]:!pr-1",
+ md: "[&_.main]:!pr-2",
+ lg: "[&_.main]:!pr-3",
+ }[size],
withoutDisabledAppearance &&
disabled &&
"disabled:pointer-events-none disabled:opacity-100 disabled:cursor-default [&[aria-disabled=true]]:opacity-100 [&[aria-disabled=true]]:cursor-default",
@@ -171,8 +191,17 @@ const ButtonInternal = forwardRef<
)}
{iconPosition === "right" && iconNode}
{append}{" "}
- {counterValue && (
-
+ {hasCounter && (
+
+
+
)}