Menu

Change language

GitHub repo

Pricing Feature Tier Slider

blockpricing

Continuous MAU and usage scale pricing slider with dynamic tier calculation and annual discount toggle.

Installation

$npx uipkge-ng@latest add pricing-feature-tier-slider
Manifest:https://uipkge.dev/r/angular/pricing-feature-tier-slider.jsonInstalls to:src/app/components/blocks/pricing-feature-tier-slider/

Variants

Loading interactive previews…

Schema

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

PricingTier
interface PricingTier {
  label: string
  mauCount: string
  basePriceMonthly: number
  tierName: string
  features: string[]
}

npm dependencies

Used by

Files installed (2)

  • src/app/components/blocks/pricing-feature-tier-slider/pricing-feature-tier-slider.component.ts10.9 kB
    // Continuous MAU usage-scale pricing slider: stepped tier calculation with an annual
    // billing toggle and per-tier deliverables. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import { ArrowRight, CheckCircle2, LucideAngularModule, Sliders } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component'
    
    export interface PricingTier {
      label: string
      mauCount: string
      basePriceMonthly: number
      tierName: string
      features: string[]
    }
    
    const TIERS: PricingTier[] = [
      {
        label: '10K MAU',
        mauCount: '10,000',
        basePriceMonthly: 19,
        tierName: 'Starter Hobbyist',
        features: [
          '10,000 Monthly Users',
          '2 Dedicated Edge Locations',
          'Community Discord SLA',
          'Standard 1-Day Log Retention',
        ],
      },
      {
        label: '50K MAU',
        mauCount: '50,000',
        basePriceMonthly: 49,
        tierName: 'Pro Creator',
        features: [
          '50,000 Monthly Users',
          '12 Global Anycast Edges',
          'Next-Business-Day Support SLA',
          '7-Day Immutable Log Retention',
        ],
      },
      {
        label: '250K MAU',
        mauCount: '250,000',
        basePriceMonthly: 149,
        tierName: 'Growth Scale',
        features: [
          '250,000 Monthly Users',
          'All 36 Global Edge Regions',
          '4-Hour Priority Engineering SLA',
          '30-Day SOC2 Audit Logs',
        ],
      },
      {
        label: '1M MAU',
        mauCount: '1,000,000',
        basePriceMonthly: 399,
        tierName: 'Enterprise Core',
        features: [
          '1,000,000 Monthly Users',
          'Dedicated VPC & Multi-Region Mesh',
          '15-Minute Critical Incident SLA',
          '365-Day Cold Storage Logs',
        ],
      },
      {
        label: '5M+ MAU',
        mauCount: '5,000,000+',
        basePriceMonthly: 899,
        tierName: 'Hyperscale Cluster',
        features: [
          '5,000,000+ Monthly Users',
          'Custom Bare-Metal Cloud Partitions',
          'Dedicated Solutions Architect',
          'Custom Security Review & BAA',
        ],
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-pricing-feature-tier-slider, [ui-pricing-feature-tier-slider]',
      standalone: true,
      // React renders the root <section> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent],
      template: `
        <section data-slot="pricing-feature-tier-slider" [class]="rootClass">
          <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
            <!-- Section Header -->
            <div class="mx-auto max-w-3xl space-y-4 text-center">
              <a
                href="#scale-pricing"
                class="group border-border/80 bg-secondary/60 hover:bg-secondary text-foreground inline-flex items-center gap-2 rounded-full border px-3.5 py-1 text-xs font-medium shadow-2xs transition-colors"
              >
                <lucide-icon [img]="Sliders" class="text-primary size-3.5" />
                <span>Continuous Scale Synthesizer</span>
                <lucide-icon
                  [img]="ArrowRight"
                  class="text-muted-foreground size-3 transition-transform group-hover:translate-x-0.5"
                />
              </a>
    
              <h2 class="text-foreground text-3xl font-bold tracking-tight sm:text-4xl">
                {{ title() }}
              </h2>
    
              <p class="text-muted-foreground text-base sm:text-lg">
                {{ description() }}
              </p>
    
              <!-- Cadence Switcher -->
              <div class="flex items-center justify-center gap-3 pt-2">
                <span [class]="cadenceLabelClass(!isAnnual())">Monthly</span>
                <button
                  type="button"
                  aria-label="Toggle annual billing"
                  [class]="cadenceTrackClass()"
                  (click)="isAnnual.set(!isAnnual())"
                >
                  <span aria-hidden="true" [class]="cadenceThumbClass()"></span>
                </button>
                <span [class]="cadenceAnnualLabelClass()">
                  <span>Annual Billing</span>
                  <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success text-xs">
                    Save 20%
                  </span>
                </span>
              </div>
            </div>
    
            <!-- Pricing Slider Main Card -->
            <div class="mx-auto mt-12 max-w-4xl">
              <div ui-card class="border-border bg-card overflow-hidden shadow-sm">
                <div ui-card-content class="space-y-8 p-8">
                  <!-- Scale Indicator Bar -->
                  <div class="border-border flex flex-wrap items-center justify-between gap-4 border-b pb-6">
                    <div class="space-y-1">
                      <span class="text-muted-foreground text-xs font-bold tracking-wider uppercase"
                        >Calculated Tier Plan</span
                      >
                      <h3 class="text-foreground text-2xl font-bold">{{ currentTier().tierName }}</h3>
                      <div class="text-muted-foreground text-xs">
                        Engineered for {{ currentTier().mauCount }} active user sessions
                      </div>
                    </div>
    
                    <!-- Price Box -->
                    <div class="text-right">
                      <div class="flex items-baseline justify-end gap-1">
                        <span class="text-foreground font-mono text-4xl font-bold">&#36;{{ effectivePrice() }}</span>
                        <span class="text-muted-foreground font-mono text-xs">/ month</span>
                      </div>
                      @if (isAnnual()) {
                        <div class="text-success font-mono text-xs font-medium">
                          Save &#36;{{ annualSavings() }}/yr on annual
                        </div>
                      }
                    </div>
                  </div>
    
                  <!-- Interactive Stepped Range Slider -->
                  <div class="space-y-3">
                    <div class="text-foreground flex items-center justify-between text-xs font-bold">
                      <span>Monthly Traffic Volume</span>
                      <span class="text-primary font-mono font-bold">{{ currentTier().mauCount }} Users</span>
                    </div>
    
                    <input
                      type="range"
                      min="0"
                      max="4"
                      step="1"
                      aria-label="Monthly traffic volume tier"
                      [value]="sliderIndex()"
                      (input)="onSliderInput($event)"
                      class="accent-primary h-2 w-full cursor-pointer"
                    />
    
                    <!-- Slider Step Labels -->
                    <div class="text-muted-foreground flex justify-between font-mono text-xs">
                      @for (t of tiers; track t.label) {
                        <span [class]="stepLabelClass(t.label)" (click)="setTierByLabel(t.label)">
                          {{ t.label }}
                        </span>
                      }
                    </div>
                  </div>
    
                  <!-- Features Checklist Grid for Current Tier -->
                  <div class="border-border space-y-3 border-t pt-4">
                    <span class="text-muted-foreground text-xs font-bold tracking-wider uppercase"
                      >Guaranteed Tier Deliverables</span
                    >
                    <div class="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
                      @for (feat of currentTier().features; track $index) {
                        <div class="text-foreground flex items-center gap-2 text-xs">
                          <lucide-icon [img]="CheckCircle2" class="text-success size-4 shrink-0" />
                          <span>{{ feat }}</span>
                        </div>
                      }
                    </div>
                  </div>
    
                  <!-- Action Button -->
                  <div class="pt-2">
                    <button ui-button size="lg" class="w-full gap-2 shadow-xs">
                      <span>Deploy with {{ currentTier().tierName }}</span>
                      <lucide-icon [img]="ArrowRight" class="size-4" />
                    </button>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </section>
      `,
    })
    export class UiPricingFeatureTierSliderComponent {
      protected readonly ArrowRight = ArrowRight
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Sliders = Sliders
    
      protected readonly tiers = TIERS
    
      @Input('title') set titleInput(v: string) {
        if (v !== undefined) this.titleSignal.set(v)
      }
      @Input('description') set descriptionInput(v: string) {
        if (v !== undefined) this.descriptionSignal.set(v)
      }
      @Input('class') className?: string
    
      private readonly titleSignal = signal('Predictable usage pricing with dynamic linear scale.')
      private readonly descriptionSignal = signal(
        'Slide to your estimated monthly active users or edge invocations to calculate your exact monthly investment.',
      )
    
      readonly title = computed(() => this.titleSignal())
      readonly description = computed(() => this.descriptionSignal())
    
      readonly sliderIndex = signal(2)
      readonly isAnnual = signal(true)
    
      readonly currentTier = computed(() => TIERS[this.sliderIndex()] ?? TIERS[2]!)
    
      readonly effectivePrice = computed(() => {
        const base = this.currentTier().basePriceMonthly
        return this.isAnnual() ? Math.round(base * 0.8) : base
      })
    
      readonly annualSavings = computed(() => (this.currentTier().basePriceMonthly - this.effectivePrice()) * 12)
    
      get rootClass(): string {
        return cn('bg-background relative overflow-hidden py-16 sm:py-24', this.className)
      }
    
      cadenceLabelClass(active: boolean): string {
        return cn('text-xs font-medium', active ? 'text-foreground font-semibold' : 'text-muted-foreground')
      }
    
      cadenceAnnualLabelClass(): string {
        return cn(
          'flex items-center gap-1.5 text-xs font-medium',
          this.isAnnual() ? 'text-foreground font-semibold' : 'text-muted-foreground',
        )
      }
    
      cadenceTrackClass(): string {
        return cn(
          'focus-visible:ring-ring relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:ring-2 focus-visible:outline-none',
          this.isAnnual() ? 'bg-primary' : 'bg-muted',
        )
      }
    
      cadenceThumbClass(): string {
        return cn(
          'bg-background pointer-events-none inline-block size-5 transform rounded-full shadow-lg ring-0 transition duration-200 ease-in-out',
          this.isAnnual() ? 'translate-x-5' : 'translate-x-0',
        )
      }
    
      stepLabelClass(label: string): string {
        return cn(
          'hover:text-foreground cursor-pointer transition-colors',
          this.currentTier().label === label ? 'text-primary font-bold' : '',
        )
      }
    
      setTierByLabel(label: string): void {
        const idx = TIERS.findIndex((t) => t.label === label)
        if (idx >= 0) this.sliderIndex.set(idx)
      }
    
      onSliderInput(event: Event): void {
        const value = Number((event.target as HTMLInputElement).value)
        if (Number.isInteger(value) && value >= 0 && value < TIERS.length) this.sliderIndex.set(value)
      }
    }
    
  • src/app/components/blocks/pricing-feature-tier-slider/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/pricing-feature-tier-slider.json