Menu

Change language

GitHub repo

Comparison Table

blockmarketing

Plan comparison feature matrix (Starter / Pro / Enterprise) with a sticky header row, monthly/yearly toggle that flips prices live, grouped feature rows (Core / Collaboration / Support), and check / minus / short-value cells. The Pro column is highlighted with a "Popular" badge and a primary tint down the whole column.

Installation

$npx uipkge-ng@latest add comparison-table
Manifest:https://uipkge.dev/r/angular/comparison-table.jsonInstalls to:src/app/components/blocks/comparison-table/

Variants

Loading interactive previews…

Schema

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

ComparisonPlan
interface ComparisonPlan {
  name: string
  monthly: number
  yearly: number
  cta: string
  popular?: boolean
}
ComparisonFeature
interface ComparisonFeature {
  label: string
  cells: [ComparisonCell, ComparisonCell, ComparisonCell]
}
ComparisonGroup
interface ComparisonGroup {
  label: string
  features: ComparisonFeature[]
}

Files installed (2)

  • src/app/components/blocks/comparison-table/comparison-table.component.ts7.7 kB
    // Plan comparison feature matrix (Starter / Pro / Enterprise) with a monthly/yearly
    // toggle that flips prices live, grouped feature rows, and check / minus / value cells.
    // The Pro column is highlighted with a "Popular" badge and a primary tint. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, signal } from '@angular/core'
    import { Check, LucideAngularModule, Minus } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiToggleGroupComponent, UiToggleGroupItemComponent } from '@/ui/toggle-group/toggle-group.component'
    
    export type BillingCycle = 'monthly' | 'yearly'
    export type ComparisonCell = boolean | string | number
    
    export interface ComparisonPlan {
      name: string
      monthly: number
      yearly: number
      cta: string
      popular?: boolean
    }
    
    export interface ComparisonFeature {
      label: string
      cells: [ComparisonCell, ComparisonCell, ComparisonCell]
    }
    
    export interface ComparisonGroup {
      label: string
      features: ComparisonFeature[]
    }
    
    const defaultPlans: ComparisonPlan[] = [
      { name: 'Starter', monthly: 9, yearly: 7, cta: 'Start free' },
      { name: 'Pro', monthly: 29, yearly: 24, cta: 'Start 14-day trial', popular: true },
      { name: 'Enterprise', monthly: 79, yearly: 65, cta: 'Talk to sales' },
    ]
    
    const defaultGroups: ComparisonGroup[] = [
      {
        label: 'Core',
        features: [
          { label: 'Projects', cells: [3, 'Unlimited', 'Unlimited'] },
          { label: 'Team seats', cells: ['5 seats', '20 seats', 'Custom'] },
          { label: 'API access', cells: [false, true, true] },
        ],
      },
      {
        label: 'Collaboration',
        features: [
          { label: 'Shared workspaces', cells: [false, true, true] },
          { label: 'Guest reviewers', cells: [false, '5 guests', 'Unlimited'] },
          { label: 'Roles & permissions', cells: [true, true, true] },
          { label: 'Audit log', cells: [false, false, true] },
        ],
      },
      {
        label: 'Support',
        features: [
          { label: 'Support channel', cells: ['Email', 'Priority email', 'Dedicated CSM'] },
          { label: 'Guided onboarding', cells: [false, true, true] },
          { label: 'Uptime SLA', cells: [false, false, '99.9%'] },
        ],
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-comparison-table, [ui-comparison-table]',
      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,
        UiToggleGroupComponent,
        UiToggleGroupItemComponent,
      ],
      template: `
        <section data-slot="comparison-table" [class]="rootClass">
          <div class="mx-auto max-w-5xl px-6 py-16">
            <div class="mb-8 flex flex-col items-center gap-4 text-center">
              <h2 class="text-2xl font-semibold tracking-tight sm:text-3xl">Compare plans</h2>
              <ui-toggle-group type="single" [value]="cycle()" (valueChange)="setCycle($event)">
                <button ui-toggle-group-item value="monthly">Monthly</button>
                <button ui-toggle-group-item value="yearly">
                  Yearly
                  <span ui-badge variant="secondary" class="ml-2">−20%</span>
                </button>
              </ui-toggle-group>
            </div>
    
            <div class="overflow-x-auto rounded-lg border">
              <table class="w-full max-w-[640px] min-w-full border-separate border-spacing-0 text-sm">
                <caption class="sr-only">
                  Feature comparison across Starter, Pro, and Enterprise plans
                </caption>
    
                <thead>
                  <tr>
                    <th
                      scope="col"
                      class="bg-background/95 supports-[backdrop-filter]:bg-background/80 text-muted-foreground sticky top-0 left-0 z-10 w-1/4 border-b p-4 text-left font-medium backdrop-blur"
                    >
                      <span class="sr-only">Feature</span>
                    </th>
                    @for (plan of plans; track plan.name) {
                      <th
                        scope="col"
                        [class]="
                          plan.popular
                            ? 'bg-primary/10 supports-[backdrop-filter]:bg-primary/10 sticky top-0 z-10 border-b p-4 text-center align-bottom backdrop-blur'
                            : 'bg-background/95 supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10 border-b p-4 text-center align-bottom backdrop-blur'
                        "
                      >
                        <div class="flex flex-col items-center gap-1.5">
                          @if (plan.popular) {
                            <span ui-badge>Popular</span>
                          }
                          <span class="text-base font-semibold tracking-tight">{{ plan.name }}</span>
                          <span class="tabular-nums">
                            <span class="text-2xl font-semibold">\${{ priceFor(plan) }}</span>
                            <span class="text-muted-foreground text-xs"> /mo</span>
                          </span>
                          <button ui-button size="sm" [variant]="plan.popular ? 'default' : 'outline'" class="mt-1 w-full">
                            {{ plan.cta }}
                          </button>
                        </div>
                      </th>
                    }
                  </tr>
                </thead>
    
                @for (group of groups; track group.label) {
                  <tbody>
                    <tr>
                      <th
                        colspan="4"
                        scope="colgroup"
                        class="bg-muted/50 text-muted-foreground border-b p-3 text-left text-xs font-semibold tracking-widest uppercase"
                      >
                        {{ group.label }}
                      </th>
                    </tr>
                    @for (feature of group.features; track feature.label) {
                      <tr class="hover:bg-muted/30">
                        <th scope="row" class="border-b p-4 text-left align-middle font-normal">
                          {{ feature.label }}
                        </th>
                        @for (cell of feature.cells; track $index) {
                          <td
                            [class]="
                              'border-b p-4 text-center align-middle' + (plans[$index].popular ? ' bg-primary/5' : '')
                            "
                          >
                            @if (cell === true) {
                              <lucide-icon [img]="Check" class="text-foreground mx-auto size-4" aria-label="Included" />
                            } @else if (cell === false) {
                              <lucide-icon
                                [img]="Minus"
                                class="text-muted-foreground/50 mx-auto size-4"
                                aria-label="Not included"
                              />
                            } @else {
                              <span class="text-xs font-medium">{{ cell }}</span>
                            }
                          </td>
                        }
                      </tr>
                    }
                  </tbody>
                }
              </table>
            </div>
          </div>
        </section>
      `,
    })
    export class UiComparisonTableComponent {
      protected readonly Check = Check
      protected readonly Minus = Minus
    
      @Input() plans: ComparisonPlan[] = defaultPlans
      @Input() groups: ComparisonGroup[] = defaultGroups
      @Input() set initialCycle(v: BillingCycle | undefined) {
        if (v) this.cycle.set(v)
      }
      @Input('class') className?: string
    
      readonly cycle = signal<BillingCycle>('monthly')
    
      get rootClass(): string {
        return cn('bg-background w-full', this.className)
      }
    
      setCycle(v: unknown): void {
        if (v === 'monthly' || v === 'yearly') this.cycle.set(v)
      }
    
      priceFor(plan: ComparisonPlan): number {
        return this.cycle() === 'monthly' ? plan.monthly : plan.yearly
      }
    }
    
  • src/app/components/blocks/comparison-table/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/comparison-table.json