UIPackage
Menu

Framework

Change language

Boilerplate repo

Subscription Churn Analytics

blockanalytics

ChartMogul and Baremetrics style SaaS subscription churn and MRR analytics dashboard. Features 4 key SaaS metrics cards (Net MRR growth, logo churn rate, net revenue churn, and LTV/CAC), an MRR movement waterfall decomposition bridge, a churn reasons distribution table with lost ARR attribution and trend indicators, and automated churn prevention playbook quick actions.

Also available for Vue ->

Installation

$npx shadcn@latest add https://uipkge.dev/r/react/subscription-churn-analytics.json
Named registry:npx shadcn@latest add @uipkge-react/subscription-churn-analyticsInstalls to:components/blocks/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
classNamestringoptional

Files installed (1)

  • components/blocks/SubscriptionChurnAnalytics.tsx22.2 kB
    'use client'
    
    import * as React from 'react'
    import {
      AlertCircle,
      ArrowDownRight,
      ArrowUpRight,
      Calendar,
      CheckCircle2,
      DollarSign,
      Download,
      PhoneCall,
      RefreshCw,
      RotateCcw,
      TrendingDown,
      TrendingUp,
      UserMinus,
      UserPlus,
    } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { Badge } from '@/components/ui/badge'
    import { Button } from '@/components/ui/button'
    import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
    import { Progress } from '@/components/ui/progress'
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
    import { Separator } from '@/components/ui/separator'
    import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
    
    export interface SubscriptionChurnAnalyticsProps {
      className?: string
    }
    
    const primaryMetrics = [
      {
        id: 'net-mrr',
        label: 'Net MRR Growth',
        value: '+$12,480',
        unit: '/ mo',
        badge: '+8.4%',
        badgeVariant: 'success' as const,
        badgeIcon: TrendingUp,
        description: 'Gross additions outpacing cancellations by 6.8x',
      },
      {
        id: 'user-churn',
        label: 'User Churn Rate',
        value: '1.8%',
        unit: '/ mo',
        badge: 'Below 2% benchmark',
        badgeVariant: 'success' as const,
        badgeIcon: CheckCircle2,
        description: 'Logo churn down 0.4% from previous quarter',
      },
      {
        id: 'revenue-churn',
        label: 'Revenue Churn Rate',
        value: '0.9%',
        unit: '/ mo',
        badge: 'Negative Net Churn',
        badgeVariant: 'outline' as const,
        badgeIcon: TrendingDown,
        description: 'Expansion ($6,450) exceeds total churn ($2,170)',
      },
      {
        id: 'ltv',
        label: 'Customer Lifetime Value',
        value: '$4,850.00',
        unit: '',
        badge: 'CAC Payback 4.2 mo',
        badgeVariant: 'secondary' as const,
        badgeIcon: DollarSign,
        description: '3.4x LTV to CAC ratio across active cohort',
      },
    ]
    
    const mrrWaterfall = [
      {
        id: 'new',
        label: 'New Subscriptions',
        amount: '+$8,200.00',
        impact: 'positive' as const,
        share: '52.9%',
        count: '38 new accounts',
        colorClass: 'text-success',
        badgeClass: 'bg-success/10 text-success border-success/20',
        barClass: 'bg-success',
        icon: UserPlus,
      },
      {
        id: 'expansion',
        label: 'Expansion / Upgrades',
        amount: '+$6,450.00',
        impact: 'positive' as const,
        share: '41.6%',
        count: '24 accounts expanded',
        colorClass: 'text-success',
        badgeClass: 'bg-success/10 text-success border-success/20',
        barClass: 'bg-success/80',
        icon: ArrowUpRight,
      },
      {
        id: 'contraction',
        label: 'Contraction / Downgrades',
        amount: '-$1,120.00',
        impact: 'warning' as const,
        share: '7.2%',
        count: '9 tier downgrades',
        colorClass: 'text-warning',
        badgeClass: 'bg-warning/10 text-warning border-warning/20',
        barClass: 'bg-warning',
        icon: ArrowDownRight,
      },
      {
        id: 'churn',
        label: 'Churn / Cancellations',
        amount: '-$1,050.00',
        impact: 'negative' as const,
        share: '6.8%',
        count: '7 accounts lost',
        colorClass: 'text-destructive',
        badgeClass: 'bg-destructive/10 text-destructive border-destructive/20',
        barClass: 'bg-destructive',
        icon: UserMinus,
      },
      {
        id: 'reactivations',
        label: 'Reactivations',
        amount: '+$850.00',
        impact: 'info' as const,
        share: '5.5%',
        count: '4 win-back returns',
        colorClass: 'text-info',
        badgeClass: 'bg-info/10 text-info border-info/20',
        barClass: 'bg-info bg-info',
        icon: RefreshCw,
      },
    ]
    
    const churnReasons = [
      {
        id: 'competitor',
        reason: 'Switched to competitor',
        category: 'Product Fit / Competition',
        share: 38,
        lostArr: '-$24,800.00',
        accounts: 19,
        trend: 'Increasing',
        trendDelta: '+12% QoQ',
        trendVariant: 'destructive' as const,
      },
      {
        id: 'budget',
        reason: 'Budget constraints',
        category: 'Economic / Spend Freeze',
        share: 28,
        lostArr: '-$18,300.00',
        accounts: 14,
        trend: 'Decreasing',
        trendDelta: '-8% QoQ',
        trendVariant: 'success' as const,
      },
      {
        id: 'missing-sso',
        reason: 'Missing feature: SAML SSO',
        category: 'Security / Compliance',
        share: 21,
        lostArr: '-$13,750.00',
        accounts: 8,
        trend: 'Decreasing',
        trendDelta: '-15% QoQ',
        trendVariant: 'success' as const,
      },
      {
        id: 'project-ended',
        reason: 'Project ended',
        category: 'Seasonal / Lifecycle',
        share: 13,
        lostArr: '-$8,500.00',
        accounts: 6,
        trend: 'Increasing',
        trendDelta: '+4% QoQ',
        trendVariant: 'warning' as const,
      },
    ]
    
    const quickActions = [
      {
        id: 'winback',
        title: 'Trigger Discount Win-Back Campaign',
        description: 'Dispatch automated 20% annual renewal incentives to 25 accounts canceled within the last 45 days.',
        impactBadge: 'Est. $4,800 MRR recovered',
        buttonText: 'Trigger Win-Back',
        buttonVariant: 'default' as const,
        icon: RotateCcw,
      },
      {
        id: 'interviews',
        title: 'Schedule Exit Discovery Interviews',
        description:
          'Book 15-minute product alignment calls with churned enterprise accounts ($10k+ ARR) for feature feedback.',
        impactBadge: '3 enterprise accounts flagged',
        buttonText: 'Schedule Interviews',
        buttonVariant: 'outline' as const,
        icon: PhoneCall,
      },
      {
        id: 'health-alerts',
        title: 'Configure Churn Risk Telemetry Alerts',
        description: 'Automate high-priority notifications when weekly active seat engagement drops below 40% threshold.',
        impactBadge: '12 accounts in risk zone',
        buttonText: 'Configure Triggers',
        buttonVariant: 'outline' as const,
        icon: AlertCircle,
      },
    ]
    
    export function SubscriptionChurnAnalytics({ className }: SubscriptionChurnAnalyticsProps) {
      const [selectedRange, setSelectedRange] = React.useState('90d')
    
      return (
        <div data-uipkge="" data-slot="subscription-churn-analytics" className={cn('w-full space-y-6', className)}>
          {/* Header Section */}
          <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
            <div className="space-y-1">
              <div className="flex items-center gap-2">
                <h2 className="text-foreground text-xl font-bold tracking-tight sm:text-2xl">
                  Subscription Churn &amp; MRR Analytics
                </h2>
                <Badge
                  variant="outline"
                  className="border-border text-muted-foreground hidden font-mono text-xs sm:inline-flex"
                >
                  SaaS Metrics
                </Badge>
              </div>
              <p className="text-muted-foreground text-sm">
                Track recurring revenue dynamics, logo retention cohorts, and cancellation root causes.
              </p>
            </div>
    
            <div className="flex flex-wrap items-center gap-3">
              <div className="w-44">
                <Select value={selectedRange} onValueChange={setSelectedRange}>
                  <SelectTrigger size="sm" className="w-full">
                    <Calendar className="text-muted-foreground mr-1 size-3.5" aria-hidden="true" />
                    <SelectValue placeholder="Select period" />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="30d">Last 30 Days</SelectItem>
                    <SelectItem value="90d">Last 90 Days</SelectItem>
                    <SelectItem value="12m">Last 12 Months</SelectItem>
                    <SelectItem value="ytd">Year to Date</SelectItem>
                  </SelectContent>
                </Select>
              </div>
    
              <Button aria-label="Download attachment" size="sm" variant="outline" className="gap-2 shadow-xs">
                <Download className="text-muted-foreground size-4" aria-hidden="true" />
                <span>Export Retention Report</span>
              </Button>
            </div>
          </div>
    
          {/* 4 Primary SaaS Metrics Cards */}
          <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            {primaryMetrics.map((metric) => {
              const BadgeIcon = metric.badgeIcon
              return (
                <Card key={metric.id} className="border-border bg-card hover:border-border/80 shadow-xs transition-colors">
                  <CardHeader className="pb-2">
                    <div className="flex items-center justify-between gap-2">
                      <CardTitle className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
                        {metric.label}
                      </CardTitle>
                      <Badge variant={metric.badgeVariant} className="gap-1 font-medium tabular-nums">
                        <BadgeIcon className="size-3" aria-hidden="true" />
                        <span>{metric.badge}</span>
                      </Badge>
                    </div>
                  </CardHeader>
                  <CardContent className="space-y-1 pb-4">
                    <div className="flex items-baseline gap-1">
                      <span className="text-foreground text-2xl font-bold tracking-tight tabular-nums sm:text-3xl">
                        {metric.value}
                      </span>
                      {metric.unit && <span className="text-muted-foreground text-xs font-normal">{metric.unit}</span>}
                    </div>
                    <p className="text-muted-foreground text-xs leading-relaxed">{metric.description}</p>
                  </CardContent>
                </Card>
              )
            })}
          </div>
    
          {/* MRR Movement Waterfall Breakdown Card */}
          <Card className="border-border bg-card shadow-xs">
            <CardHeader className="pb-4">
              <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                <div>
                  <div className="flex items-center gap-2">
                    <CardTitle className="text-foreground text-base font-semibold">
                      MRR Movement Waterfall Breakdown
                    </CardTitle>
                    <Badge variant="outline" className="border-border bg-muted/30 text-xs font-medium">
                      ChartMogul Bridge
                    </Badge>
                  </div>
                  <CardDescription className="mt-1 text-xs">
                    Decomposition of new customer revenue, expansion, contractions, logo churn, and reactivations.
                  </CardDescription>
                </div>
                <div className="border-border/80 bg-muted/20 flex items-center gap-2 self-start rounded-lg border px-3 py-1.5 sm:self-auto">
                  <span className="text-muted-foreground text-xs font-medium">Net MRR Change:</span>
                  <span className="text-success text-sm font-bold tabular-nums">+$13,330.00</span>
                  <Badge variant="success" className="ml-1 text-xs">
                    +9.3%
                  </Badge>
                </div>
              </div>
            </CardHeader>
    
            <CardContent className="space-y-6">
              {/* Visual Waterfall Proportional Bar */}
              <div className="space-y-2">
                <div className="text-muted-foreground flex items-center justify-between text-xs">
                  <span>
                    Starting MRR: <strong className="text-foreground font-semibold tabular-nums">$142,500.00</strong>
                  </span>
                  <span>
                    Ending MRR: <strong className="text-foreground font-semibold tabular-nums">$155,830.00</strong>
                  </span>
                </div>
                <div className="bg-muted/60 flex h-3 w-full overflow-hidden rounded-full p-0.5 shadow-inner">
                  <div
                    className="bg-success h-full rounded-l-full transition-[width] duration-300"
                    style={{ width: '48%' }}
                    title="New Subscriptions (+$8,200.00)"
                  />
                  <div
                    className="bg-success/90 h-full transition-[width] duration-300"
                    style={{ width: '38%' }}
                    title="Expansion (+$6,450.00)"
                  />
                  <div
                    className="bg-info h-full transition-[width] duration-300"
                    style={{ width: '5%' }}
                    title="Reactivations (+$850.00)"
                  />
                  <div
                    className="bg-warning h-full transition-[width] duration-300"
                    style={{ width: '5%' }}
                    title="Contraction (-$1,120.00)"
                  />
                  <div
                    className="bg-destructive h-full rounded-r-full transition-[width] duration-300"
                    style={{ width: '4%' }}
                    title="Churn (-$1,050.00)"
                  />
                </div>
                <div className="text-muted-foreground flex flex-wrap items-center justify-between gap-2 pt-1 text-xs">
                  <div className="flex items-center gap-1.5">
                    <span className="bg-success inline-block size-2 rounded-full" />
                    <span>New (+$8.2k)</span>
                  </div>
                  <div className="flex items-center gap-1.5">
                    <span className="bg-success inline-block size-2 rounded-full" />
                    <span>Expansion (+$6.45k)</span>
                  </div>
                  <div className="flex items-center gap-1.5">
                    <span className="bg-info inline-block size-2 rounded-full" />
                    <span>Reactivation (+$850)</span>
                  </div>
                  <div className="flex items-center gap-1.5">
                    <span className="bg-warning inline-block size-2 rounded-full" />
                    <span>Contraction (-$1.12k)</span>
                  </div>
                  <div className="flex items-center gap-1.5">
                    <span className="bg-destructive inline-block size-2 rounded-full" />
                    <span>Churn (-$1.05k)</span>
                  </div>
                </div>
              </div>
    
              <Separator className="border-border" />
    
              {/* Detailed Waterfall Grid */}
              <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
                {mrrWaterfall.map((item) => {
                  const ItemIcon = item.icon
                  return (
                    <div
                      key={item.id}
                      className="border-border bg-card/60 hover:bg-muted/20 flex items-start justify-between rounded-lg border p-3.5 shadow-xs transition-colors"
                    >
                      <div className="flex items-start gap-3">
                        <div
                          className={cn(
                            'border-border flex size-9 shrink-0 items-center justify-center rounded-md border shadow-xs',
                            item.badgeClass,
                          )}
                        >
                          <ItemIcon className="size-4" aria-hidden="true" />
                        </div>
                        <div className="space-y-0.5">
                          <p className="text-foreground text-xs font-semibold">{item.label}</p>
                          <p className="text-muted-foreground text-xs">{item.count}</p>
                        </div>
                      </div>
                      <div className="text-right">
                        <p className={cn('text-sm font-bold tabular-nums', item.colorClass)}>{item.amount}</p>
                        <span className="text-muted-foreground text-xs tabular-nums">{item.share} gross</span>
                      </div>
                    </div>
                  )
                })}
    
                {/* Net Summary Tile */}
                <div className="border-success/30 bg-success/5 flex items-start justify-between rounded-lg border p-3.5 shadow-xs">
                  <div className="flex items-start gap-3">
                    <div className="border-success/30 bg-success/15 text-success flex size-9 shrink-0 items-center justify-center rounded-md border shadow-xs">
                      <TrendingUp className="size-4" aria-hidden="true" />
                    </div>
                    <div className="space-y-0.5">
                      <p className="text-foreground text-xs font-semibold">Net MRR Movement</p>
                      <p className="text-muted-foreground text-xs">All streams combined</p>
                    </div>
                  </div>
                  <div className="text-right">
                    <p className="text-success text-sm font-bold tabular-nums">+$13,330.00</p>
                    <Badge variant="success" className="text-xs tabular-nums">
                      Net Positive
                    </Badge>
                  </div>
                </div>
              </div>
            </CardContent>
          </Card>
    
          {/* Churn Reason Distribution Table */}
          <Card className="border-border bg-card shadow-xs">
            <CardHeader className="pb-3">
              <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                <div>
                  <CardTitle className="text-foreground text-base font-semibold">
                    Churn Reason Distribution &amp; Lost ARR Attribution
                  </CardTitle>
                  <CardDescription className="mt-1 text-xs">
                    Primary cancellation drivers captured via post-cancellation exit surveys and customer success logs.
                  </CardDescription>
                </div>
                <div className="flex items-center gap-2">
                  <span className="text-muted-foreground text-xs">Total Lost ARR:</span>
                  <span className="text-destructive text-sm font-bold tabular-nums">-$65,350.00</span>
                </div>
              </div>
            </CardHeader>
            <CardContent className="p-0">
              <div className="overflow-x-auto">
                <Table>
                  <TableHeader className="bg-muted/40">
                    <TableRow className="border-border hover:bg-transparent">
                      <TableHead className="text-foreground text-xs font-semibold">Cancellation Reason</TableHead>
                      <TableHead className="text-foreground text-xs font-semibold">Distribution Share</TableHead>
                      <TableHead className="text-foreground text-right text-xs font-semibold">Lost ARR ($)</TableHead>
                      <TableHead className="text-foreground text-right text-xs font-semibold">Impacted Accounts</TableHead>
                      <TableHead className="text-foreground text-right text-xs font-semibold">Quarterly Trend</TableHead>
                    </TableRow>
                  </TableHeader>
                  <TableBody>
                    {churnReasons.map((row) => (
                      <TableRow key={row.id} className="border-border hover:bg-muted/30 transition-colors">
                        <TableCell className="py-3.5">
                          <div className="text-foreground text-sm font-medium">{row.reason}</div>
                          <div className="text-muted-foreground text-xs">{row.category}</div>
                        </TableCell>
                        <TableCell className="min-w-[180px] py-3.5">
                          <div className="space-y-1.5">
                            <div className="flex items-center justify-between text-xs">
                              <span className="text-foreground font-medium tabular-nums">{row.share}%</span>
                              <span className="text-muted-foreground text-xs">of total churn</span>
                            </div>
                            <Progress value={row.share} className="bg-muted h-1.5" />
                          </div>
                        </TableCell>
                        <TableCell className="text-foreground py-3.5 text-right text-sm font-semibold tabular-nums">
                          {row.lostArr}
                        </TableCell>
                        <TableCell className="text-muted-foreground py-3.5 text-right text-xs tabular-nums">
                          <span className="text-foreground font-medium">{row.accounts}</span> accounts
                        </TableCell>
                        <TableCell className="py-3.5 text-right">
                          <Badge variant={row.trendVariant} className="gap-1 text-xs font-medium tabular-nums">
                            <span>{row.trend === 'Increasing' ? '' : ''}</span>
                            <span>{row.trend}</span>
                            <span className="opacity-75">({row.trendDelta})</span>
                          </Badge>
                        </TableCell>
                      </TableRow>
                    ))}
                  </TableBody>
                </Table>
              </div>
            </CardContent>
          </Card>
    
          {/* Churn Prevention Quick Actions */}
          <Card className="border-border bg-card shadow-xs">
            <CardHeader className="pb-3">
              <div className="flex items-center gap-2">
                <CardTitle className="text-foreground text-base font-semibold">
                  Churn Prevention &amp; Win-Back Quick Actions
                </CardTitle>
                <Badge variant="secondary" className="text-xs">
                  Playbooks
                </Badge>
              </div>
              <CardDescription className="text-xs">
                High-leverage intervention workflows to stem revenue loss and re-engage churned customer cohorts.
              </CardDescription>
            </CardHeader>
            <CardContent>
              <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
                {quickActions.map((action) => {
                  const ActionIcon = action.icon
                  return (
                    <div
                      key={action.id}
                      className="border-border bg-card/60 hover:border-border/80 hover:bg-muted/10 flex flex-col justify-between rounded-lg border p-4 shadow-xs transition-colors"
                    >
                      <div className="space-y-3">
                        <div className="flex items-center justify-between">
                          <div className="border-border bg-muted/40 text-foreground flex size-9 items-center justify-center rounded-md border shadow-xs">
                            <ActionIcon className="size-4" aria-hidden="true" />
                          </div>
                          <Badge variant="outline" className="border-border text-muted-foreground font-mono text-xs">
                            {action.impactBadge}
                          </Badge>
                        </div>
                        <div className="space-y-1">
                          <h4 className="text-foreground text-sm leading-snug font-semibold">{action.title}</h4>
                          <p className="text-muted-foreground text-xs leading-relaxed">{action.description}</p>
                        </div>
                      </div>
    
                      <div className="mt-4 pt-2">
                        <Button variant={action.buttonVariant} size="sm" className="w-full gap-2 shadow-xs">
                          <ActionIcon className="size-3.5" aria-hidden="true" />
                          <span>{action.buttonText}</span>
                        </Button>
                      </div>
                    </div>
                  )
                })}
              </div>
            </CardContent>
          </Card>
        </div>
      )
    }
    
    export default SubscriptionChurnAnalytics
    

Raw manifest:https://uipkge.dev/r/react/subscription-churn-analytics.json