# Drip Badge

> A notification count made of liquid: a new one makes a droplet drip off the bottom, reading one pinches a piece off the side, and the number rolls either way.

- Page: https://devl.dev/components/drip-badge
- Kind: component · Liquid
- Install: `npx shadcn@latest add https://devl.dev/r/drip-badge.json`
- Raw source: https://devl.dev/raw/drip-badge.tsx
- npm: motion
- License: MIT. The coss ui components it uses (https://coss.com/ui) are AGPL-3.0.

## Usage

```tsx
"use client";

import { BellIcon, CheckCheckIcon, InboxIcon, MessageCircleIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { DripBadge } from "@/components/drip-badge";

export default function DripBadgeDemo() {
  const [mentions, setMentions] = useState(8);
  const [inbox, setInbox] = useState(3);

  useEffect(() => {
    const ids = [window.setTimeout(() => setMentions((n) => n + 1), 900), window.setTimeout(() => setMentions((n) => n + 1), 2100)];
    return () => ids.forEach(clearTimeout);
  }, []);

  return (
    <div className="grid w-80 max-w-full gap-5 rounded-2xl border bg-card p-5 shadow-xs/5">
      <div className="flex items-center justify-between">
        <p className="font-medium text-sm">Northwind</p>
        <div className="flex items-center gap-3">
          <DripBadge count={inbox} tone="info">
            <Button aria-label={`Inbox, ${inbox} unread`} onClick={() => setInbox(0)} size="icon" variant="ghost">
              <InboxIcon />
            </Button>
          </DripBadge>
          <DripBadge count={0}>
            <Button aria-label="Messages" size="icon" variant="ghost">
              <MessageCircleIcon />
            </Button>
          </DripBadge>
          <DripBadge count={mentions}>
            <Button aria-label={`Notifications, ${mentions} unread`} size="icon" variant="ghost">
              <BellIcon />
            </Button>
          </DripBadge>
        </div>
      </div>
      <div className="grid grid-cols-2 gap-2">
        <Button onClick={() => setMentions((n) => n + 1)} size="sm" variant="outline">
          <PlusIcon />
          New ping
        </Button>
        <Button disabled={mentions === 0} onClick={() => setMentions((n) => Math.max(0, n - 1))} size="sm" variant="outline">
          <CheckCheckIcon />
          Read one
        </Button>
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default |
| --- | --- | --- |
| `count` | `number` |  |
| `max`? | `number` | `99` |
| `tone`? | `keyof typeof TONES` | `"destructive"` |
| `children` | `ReactNode` |  |
| `showZero`? | `boolean` | `false` |
| `className`? | `string` |  |

## Source: components/drip-badge.tsx

```tsx
"use client";

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";

const TONES = {
  destructive: "[--drip:var(--color-destructive)] text-white",
  primary: "[--drip:var(--color-primary)] text-primary-foreground",
  info: "[--drip:var(--color-info)] text-white",
  success: "[--drip:var(--color-success)] text-white",
} as const;

const H = 18;
const PAD = 14;

const roll = {
  enter: (d: number) => ({ y: d * 9, opacity: 0 }),
  center: { y: 0, opacity: 1 },
  exit: (d: number) => ({ y: -d * 9, opacity: 0 }),
};
// Same keys as `roll` so the server and a reduced-motion client render identical styles.
const fade = { enter: { y: 0, opacity: 0 }, center: { y: 0, opacity: 1 }, exit: { y: 0, opacity: 0 } };

type Splash = { id: number; kind: "drip" | "split" };

export interface DripBadgeProps {
  count: number;
  max?: number;
  tone?: keyof typeof TONES;
  children: ReactNode;
  showZero?: boolean;
  className?: string;
}

export function DripBadge({ count, max = 99, tone = "destructive", children, showZero = false, className }: DripBadgeProps) {
  const reduce = useReducedMotion();
  const filterId = `drip-badge-${useId().replace(/:/g, "")}`;
  const previous = useRef(count);
  const nextId = useRef(0);
  const [splashes, setSplashes] = useState<Splash[]>([]);
  const [shown, setShown] = useState(count);
  const [direction, setDirection] = useState<1 | -1>(1);
  if (count !== shown) {
    setShown(count);
    setDirection(count > shown ? 1 : -1);
  }

  useEffect(() => {
    const before = previous.current;
    previous.current = count;
    if (count === before) return;
    if (reduce || (count === 0 && !showZero) || (before === 0 && !showZero)) return;
    const splash: Splash = { id: nextId.current++, kind: count > before ? "drip" : "split" };
    setSplashes((s) => [...s.slice(-2), splash]);
  }, [count, reduce, showZero]);

  const visible = count > 0 || showZero;
  const label = count > max ? `${max}+` : String(count);
  const width = Math.max(H, Math.round(label.length * 6.6 + 10));

  return (
    <span className={cn("relative inline-flex", className)}>
      {children}
      <span aria-live="polite" className="sr-only">
        {count === 0 ? "No unread notifications" : `${count} unread`}
      </span>
      <span
        aria-hidden
        className={cn("pointer-events-none absolute", TONES[tone])}
        style={{ top: -6 - PAD, left: `calc(100% - ${12 + PAD}px)`, width: 48 + PAD * 2, height: H + PAD * 2 + 22 }}
      >
        <svg className="absolute size-0">
          <filter colorInterpolationFilters="sRGB" height="100%" id={filterId} width="100%" x="0" y="0">
            <feGaussianBlur in="SourceGraphic" result="blur" stdDeviation="2.6" />
            <feColorMatrix in="blur" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 24 -10" />
          </filter>
        </svg>
        <span className="absolute inset-0" style={{ filter: `url(#${filterId})` }}>
          <AnimatePresence initial={false}>
            {visible && (
              <motion.span
                animate={{ scale: 1, opacity: 1, width }}
                className="absolute rounded-full bg-(--drip)"
                exit={{ scale: 0.5, opacity: 0 }}
                initial={{ scale: 0.5, opacity: 0, width }}
                key="pill"
                style={{ left: PAD, top: PAD, height: H, originX: 0.5 }}
                transition={{ type: "spring", stiffness: 520, damping: 30 }}
              />
            )}
          </AnimatePresence>
          {splashes.map((splash) =>
            splash.kind === "drip" ? (
              <motion.span
                animate={{ y: [0, 6, 15, 32], scale: [0.6, 1, 0.92, 0.7], opacity: [1, 1, 1, 0] }}
                className="absolute rounded-full bg-(--drip)"
                key={splash.id}
                onAnimationComplete={() => setSplashes((s) => s.filter((x) => x.id !== splash.id))}
                style={{ left: PAD + width / 2 - 4, top: PAD + H - 9, width: 8, height: 8 }}
                transition={{ duration: 0.7, times: [0, 0.3, 0.55, 1], ease: ["easeOut", "easeIn", "easeIn"] }}
              />
            ) : (
              <motion.span
                animate={{ x: [0, 9, 20], scale: [1, 0.85, 0.2], opacity: [1, 1, 0] }}
                className="absolute rounded-full bg-(--drip)"
                key={splash.id}
                onAnimationComplete={() => setSplashes((s) => s.filter((x) => x.id !== splash.id))}
                style={{ left: PAD + width - 12, top: PAD + (H - 12) / 2, width: 12, height: 12 }}
                transition={{ duration: 0.5, times: [0, 0.4, 1], ease: ["easeOut", "easeIn"] }}
              />
            ),
          )}
        </span>
        <AnimatePresence initial={false}>
          {visible && (
            <motion.span
              animate={{ opacity: 1, scale: 1, width }}
              className="absolute flex items-center justify-center overflow-hidden font-semibold text-[11px] tabular-nums leading-none"
              exit={{ opacity: 0, scale: 0.5 }}
              initial={{ opacity: 0, scale: 0.5, width }}
              key="label"
              style={{ left: PAD, top: PAD, height: H }}
              transition={{ type: "spring", stiffness: 520, damping: 30 }}
            >
              <AnimatePresence custom={direction} initial={false} mode="popLayout">
                <motion.span
                  animate="center"
                  custom={direction}
                  exit="exit"
                  initial="enter"
                  key={label}
                  variants={reduce ? fade : roll}
                  transition={{ duration: 0.18, ease: [0.23, 1, 0.32, 1] }}
                >
                  {label}
                </motion.span>
              </AnimatePresence>
            </motion.span>
          )}
        </AnimatePresence>
      </span>
    </span>
  );
}
```
