UIPackage

Conversion Funnel

block dashboard
Edit on GitHub

Horizontal conversion funnel with count strip above the SmoothFunnel chart and a stage-name + retention-from-previous footer below. Works for hiring (applied → hired), e-commerce (sessions → purchases), onboarding (signed up → activated). 3-6 stages. Theme-aware via OKLCH chart tokens.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/conversion-funnel.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/conversion-funnel

Examples

Live demo coming soon -- source below is exact.

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

Stage
interface Stage {
  name: string
  value: number
}

Includes

Theming

CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.

--chart-1--chart-2--chart-3--chart-4--chart-5

Files (1)

  • app/components/blocks/ConversionFunnel.vue 3.2 kB
    <script setup lang="ts">
    /**
     * Conversion funnel: SmoothFunnel chart with count strip above and
     * stage-name + retention-from-previous strip below.
     *
     * Works for any drop-off flow with 3-6 stages: hiring (applied ->
     * hired), e-commerce (sessions -> purchases), onboarding (signed up
     * -> activated), etc. Reads as a single horizontal "flow":
     *
     *   72K        38.2K       16.8K       5.6K
     *   ╔══════╗╔════════╗╔══════════╗╔══════════╗
     *   ║ 100% ║║  53%   ║║   23%    ║║    8%    ║
     *   ╚══════╝╚════════╝╚══════════╝╚══════════╝
     *   Views      Cart        Checkout    Purchase
     *              -> 53%       -> 44%      -> 33%
     *
     * The middle pill (inside each band) shows the share OF THE TOP
     * stage (100%, 53%, 23%, ...). The bottom retention number shows the
     * stage-to-stage conversion -- usually the more actionable signal.
     */
    import { computed } from 'vue'
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    import { SmoothFunnel } from '@/components/ui/charts/smooth-funnel'
    
    interface Stage {
      name: string
      value: number
    }
    
    interface Props {
      data: Stage[]
      /** SmoothFunnel container height. Defaults to 180. */
      height?: number | string
      /** Override colour palette. Defaults to the registry's chart-1..N
       *  OKLCH tokens via CSS variables (so light/dark + theme-customizer
       *  flips ripple through without any JS). Pass a literal hex array to
       *  override. */
      colors?: string[]
      /** Compact number formatter for the count strip (default toLocaleString). */
      format?: (n: number) => string
      /** Hide the retention-from-previous footer on each non-first stage. */
      hideRetention?: boolean
      class?: HTMLAttributes['class']
    }
    
    const DEFAULT_PALETTE = [
      'var(--chart-1)',
      'var(--chart-2)',
      'var(--chart-3)',
      'var(--chart-4)',
      'var(--chart-5)',
      'var(--chart-1)',
    ]
    
    const props = withDefaults(defineProps<Props>(), {
      height: 180,
      hideRetention: false,
    })
    
    const palette = computed(() => props.colors ?? DEFAULT_PALETTE)
    const fmt = (v: number) => (props.format ? props.format(v) : v.toLocaleString())
    const retention = (i: number) => {
      if (i === 0) return null
      const prev = props.data[i - 1]?.value ?? 1
      return Math.round((props.data[i]!.value / prev) * 100)
    }
    </script>
    
    <template>
      <div data-uipkge data-slot="conversion-funnel" :class="cn('space-y-2', props.class)">
        <!-- Top: per-stage count -->
        <div class="grid px-2 text-center" :style="{ gridTemplateColumns: `repeat(${data.length}, minmax(0, 1fr))` }">
          <div v-for="stage in data" :key="`top-${stage.name}`" class="text-foreground text-base font-bold tabular-nums">
            {{ fmt(stage.value) }}
          </div>
        </div>
    
        <!-- Funnel SVG -->
        <SmoothFunnel :data="data" :height="height" :colors="palette" />
    
        <!-- Bottom: stage name + retention from previous -->
        <div class="grid px-2 text-center" :style="{ gridTemplateColumns: `repeat(${data.length}, minmax(0, 1fr))` }">
          <div v-for="(stage, i) in data" :key="`bot-${stage.name}`" class="space-y-0.5">
            <p class="text-muted-foreground text-xs">{{ stage.name }}</p>
            <p v-if="!hideRetention && retention(i) !== null" class="text-info text-[10px] font-semibold tabular-nums">
              → {{ retention(i) }}% retained
            </p>
          </div>
        </div>
      </div>
    </template>

Raw manifest: https://uipkge.dev/r/vue/conversion-funnel.json