Volver al blog

Tablas comparativas en React para páginas de precios SaaS

Publicado el 20 de marzo de 2026·7 min read

Una página de precios convierte o no convierte. El componente que más peso tiene en cualquier página de precios SaaS es la tabla comparativa, el lugar donde los clientes potenciales colocan los planes uno al lado del otro y deciden si vale la pena mejorar. Una tabla mal construida pierde ventas. Una tabla clara, fácil de leer y adaptada a móvil las cierra. Esta guía explica cómo construir tablas comparativas de calidad profesional en React, desde rejillas básicas hasta matrices de funciones completas con toggles de facturación.

La rejilla comparativa básica

Empieza por la estructura más simple posible: una rejilla donde cada columna es un plan y cada fila es una función. Los props tipados con TypeScript mantienen limpia la capa de datos:

interface Plan {
  name: string;
  price: { monthly: number; annual: number };
  features: Record<string, boolean | string>;
  highlighted?: boolean;
}

interface ComparisonTableProps {
  plans: Plan[];
  features: string[];
  billingCycle: "monthly" | "annual";
}

El array features define el orden de las filas. El record features de cada plan asocia los nombres de funciones a un boolean (marca de verificación o guion) o a un valor de tipo string ("10 GB", "Ilimitado"). Este enfoque es lo bastante flexible para cubrir la mayoría de estructuras de precios SaaS sin caer en la sobreingeniería de los tipos.

Matriz de funciones con marcas de verificación

La matriz de funciones es el núcleo de cualquier página comparativa. Renderízala como un CSS Grid en lugar de una <table> HTML, ya que las rejillas te dan mucho más control sobre el comportamiento responsive:

export default function ComparisonTable({ plans, features, billingCycle }: ComparisonTableProps) {
  const columns = plans.length + 1; // +1 for feature label column

  return (
    <div
      style={{
        display: "grid",
        gridTemplateColumns: `minmax(200px, 1.5fr) repeat(${plans.length}, 1fr)`,
        gap: 0,
        width: "100%",
        maxWidth: "var(--container-max-width)",
        margin: "0 auto",
      }}
    >
      {/* Header row */}
      <div style={{ padding: "1.5rem 1rem" }} />
      {plans.map((plan) => (
        <div
          key={plan.name}
          style={{
            padding: "1.5rem 1rem",
            textAlign: "center",
            background: plan.highlighted ? "var(--color-accent-subtle)" : "transparent",
            borderRadius: plan.highlighted ? "var(--radius-lg) var(--radius-lg) 0 0" : 0,
          }}
        >
          <h3 style={{ fontSize: "1.25rem", fontWeight: 700, color: "var(--color-foreground)" }}>
            {plan.name}
          </h3>
          <p style={{ fontSize: "2rem", fontWeight: 700, marginTop: "0.5rem" }}>
            ${billingCycle === "monthly" ? plan.price.monthly : plan.price.annual}
            <span style={{ fontSize: "0.875rem", fontWeight: 400, color: "var(--color-foreground-muted)" }}>
              /mo
            </span>
          </p>
        </div>
      ))}

      {/* Feature rows */}
      {features.map((feature, i) => (
        <>
          <div
            key={`label-${feature}`}
            style={{
              padding: "1rem",
              borderTop: "1px solid var(--color-border)",
              color: "var(--color-foreground-muted)",
              fontSize: "0.9375rem",
              display: "flex",
              alignItems: "center",
            }}
          >
            {feature}
          </div>
          {plans.map((plan) => {
            const value = plan.features[feature];
            return (
              <div
                key={`${plan.name}-${feature}`}
                style={{
                  padding: "1rem",
                  borderTop: "1px solid var(--color-border)",
                  textAlign: "center",
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  background: plan.highlighted ? "var(--color-accent-subtle)" : "transparent",
                }}
              >
                {value === true && <CheckIcon />}
                {value === false && <span style={{ color: "var(--color-foreground-muted)" }}>, </span>}
                {typeof value === "string" && (
                  <span style={{ fontWeight: 500 }}>{value}</span>
                )}
              </div>
            );
          })}
        </>
      ))}
    </div>
  );
}

La marca plan.highlighted añade un fondo de acento sutil a la columna del plan recomendado. Ese peso visual atrae la mirada de forma natural, sin que haga falta una insignia de "Más popular" (aunque puedes añadirla).

Toggle de facturación: anual frente a mensual

El toggle de facturación es un componente controlado que se coloca encima de la tabla. Guarda el estado en el componente padre y pasa billingCycle hacia abajo:

"use client";

import { useState } from "react";

export default function PricingSection() {
  const [billing, setBilling] = useState<"monthly" | "annual">("annual");

  return (
    <section style={{ padding: "var(--section-padding-y-lg) 0" }}>
      <div style={{ display: "flex", justifyContent: "center", gap: "0.5rem", marginBottom: "3rem" }}>
        {(["monthly", "annual"] as const).map((cycle) => (
          <button
            key={cycle}
            onClick={() => setBilling(cycle)}
            style={{
              padding: "0.625rem 1.5rem",
              borderRadius: "var(--radius-full)",
              border: "1px solid var(--color-border)",
              background: billing === cycle ? "var(--color-accent)" : "transparent",
              color: billing === cycle ? "#fff" : "var(--color-foreground-muted)",
              fontWeight: 500,
              fontSize: "0.875rem",
              cursor: "pointer",
              transition: "all var(--duration-normal) var(--ease-out)",
            }}
          >
            {cycle === "monthly" ? "Monthly" : "Annual (save 20%)"}
          </button>
        ))}
      </div>

      <ComparisonTable plans={plans} features={features} billingCycle={billing} />
    </section>
  );
}

Consejo: pon siempre "annual" como valor por defecto. Los planes anuales tienen un LTV más alto y la mayoría de las empresas SaaS quieren empujar a los usuarios hacia ellos. La etiqueta del toggle debería indicar el ahorro de forma explícita, ya que "Annual (save 20%)" convierte mejor que un simple "Annual".

Destacar el plan recomendado

Más allá del color de fondo sutil, puedes añadir un acento en el borde superior y una insignia:

{plan.highlighted && (
  <div
    style={{
      position: "absolute",
      top: "-1px",
      left: 0,
      right: 0,
      height: "3px",
      background: "var(--color-accent)",
      borderRadius: "var(--radius-lg) var(--radius-lg) 0 0",
    }}
  />
)}

Esta barra de acento de 3px se ve sin resultar intrusiva. Combínala con una pequeña insignia tipo pastilla que diga "Recomendado" o "Más popular" y la columna destacada se convertirá en la opción por defecto evidente.

Tablas responsive para móvil

Las tablas comparativas son célebres por lo difíciles que resultan en pantallas pequeñas. Una rejilla de 4 columnas no cabe en un viewport de 375px. Hay dos enfoques que funcionan bien:

Scroll horizontal: envuelve la rejilla en un contenedor con overflow-x: auto y un ancho mínimo en la propia rejilla. Añade un degradado sutil que se desvanezca en el borde derecho para indicar que se puede desplazar:

<div style={{ position: "relative", overflow: "hidden" }}>
  <div style={{ overflowX: "auto", WebkitOverflowScrolling: "touch" }}>
    <div style={{ minWidth: "700px" }}>
      <ComparisonTable {...props} />
    </div>
  </div>
  <div
    aria-hidden
    style={{
      position: "absolute",
      top: 0,
      right: 0,
      bottom: 0,
      width: "40px",
      background: "linear-gradient(to right, transparent, var(--color-background))",
      pointerEvents: "none",
    }}
  />
</div>

Tarjetas apiladas: en móvil, cambia de una rejilla a tarjetas de plan apiladas, donde cada tarjeta enumera sus propias funciones. Usa una media query o un hook useMediaQuery para intercambiar los diseños. Este enfoque da más trabajo, pero ofrece una mejor experiencia móvil en páginas con 4 o más planes.

Accesibilidad

Las tablas comparativas tienen que poder navegarse con lectores de pantalla. Si usas CSS Grid en lugar de <table>, añade roles ARIA:

  • role="table" en el contenedor de la rejilla
  • role="row" en cada fila lógica
  • role="columnheader" en las celdas del nombre del plan
  • role="rowheader" en las celdas de etiqueta de función
  • role="cell" en las celdas de valor

Los iconos de marca de verificación necesitan aria-label="Included" y los iconos de guion necesitan aria-label="Not included". Sin esto, los usuarios de lectores de pantalla no oyen nada en esas celdas.

Secciones comparativas listas para producción

Construir una tabla comparativa que gestione bien los toggles de facturación, los planes destacados, los diseños responsive y la accesibilidad supone una cantidad de trabajo nada despreciable. El catálogo de precios de Incubator incluye varias variantes de tabla comparativa, tarjetas lado a lado, matrices de funciones completas y diseños con toggle, todas construidas con el sistema de tokens CSS y los props de TypeScript descritos en esta guía. Si necesitas una página comparativa dedicada, revisa el catálogo de secciones comparativas para ver diseños de página completa con agrupación por categorías y filas plegables. Cada variante está lista para copiar y pegar en tu proyecto Next.js.

Relacionado en incubator

VA

Victor Aubague

Desarrollador y creador de Incubator

Desarrollador full-stack especializado en React, Next.js y TypeScript. Creé Incubator para ayudar a los desarrolladores a lanzar interfaces bonitas más rápido, todos los componentes provienen de proyectos reales de clientes y de código en producción.

LinkedIn

Related articles

Tablas comparativas en React para páginas de precios SaaS