Skip to content

Product Comparison Table — Architecture & Usage Guide

Www::ProductCompareTableComponent renders a responsive, scrollable product comparison table used on all product controls / thermostat pages. Each product appears as a column; feature rows run horizontally with a sticky label column on the left.

The component is driven entirely by a static SKU_FEATURES hash — no additional database queries are needed beyond the standard product presenter load. This keeps it fast and easy to maintain.

Used on:

  • floor-heating/thermostats
  • radiant-heat-panels/controls
  • snow-melting/controls
  • towel-warmer/controls
  • pipe-freeze-protection/controls
  • roof-and-gutter-deicing/controls

FilePurpose
app/components/www/product_compare_table_component.rbRuby component: data loading, feature lookup, helpers
app/components/www/product_compare_table_component.html.erbERB template: 5-sub-row header + feature rows
client/stylesheets/www/03-components/product_compare_table_component.scssScoped SCSS: sticky column, cell alignment, responsive breakpoints

Www::ProductCompareTableComponent.new(
skus: Array, # required — ordered list of product SKUs
features: Array, # optional — feature keys to show; auto-detected if nil
section_title: String, # optional — H2 above the table
section_description: String, # optional — paragraph below the title
sort_by_price: Boolean # default: true — set false to preserve SKU array order
)

By default products are sorted ascending by effective_price. Pass sort_by_price: false when the SKU array encodes a meaningful order (e.g. thermostat tier: WiFi → Programmable → Non-programmable → Power module). All other callers retain the default true.


The header is split into 4 dedicated sub-rows so that varying title / tagline lengths never push sibling product columns out of vertical alignment:

┌────────────────────────────────────────────────────────┐
│ IMAGE ROW │ [img] │ [img] │ [img] │ [img] │ align-items: end
│ TITLE ROW │ Title │ Title │ Title │ Title │ align-items: stretch
│ TAGLINE ROW* │ tag │ tag │ tag │ tag │ align-items: stretch
│ SKU+PRICE ROW │ [sku] │ [sku] │ [sku] │ [sku] │ align-items: stretch
│ "Features" ↙ │ $249 │ $259 │ $159 │ $149 │
├────────────────┼───────┼───────┼───────┼───────────────┤
│ WiFi-Enabled │ ✓ │ ✗ │ ✗ │ ✗ │ feature rows
│ Programmable │ ✓ │ ✓ │ ✗ │ — │
│ … │ │ │ │ │
├────────────────┼───────┼───────┼───────┼───────────────┤
│ │[View] │[View] │[View] │ [View] │ CTA row
└────────────────┴───────┴───────┴───────┴───────────────┘

* Tagline row is omitted when no product has a short_description.


Each entry in SKU_FEATURES[sku][feature_key] can be:

ValueRenders as
trueGreen check-circle icon (with tooltip popover if tip defined)
falseMuted × icon
StringSmall text badge (e.g. "Up to 10 schedules", "LED touch")
:aerial / :slabYellow “Pair with…” badge linking to the sensors section
nil / absent”N/A” in muted text

Sensor features (moisture_sensor, air_temp_sensor, slab_temp_sensor) additionally render true as a green “Included” pill badge instead of a plain checkmark.


  1. Add the SKU to SKU_FEATURES in product_compare_table_component.rb:
'MY-SKU' => {
'wifi' => true,
'remote_support' => false,
'voice_control' => true,
'programmable' => true,
'schedules' => 'Up to 6 per day',
'floor_sensor_included' => true,
'display' => '3.5" touchscreen',
'max_load' => '15A @ 120/240V'
},
  1. Include the SKU in the page’s SKU array:
tstat_skus = %w[UWG5-4999-WY MY-SKU UTN5-4999]
  1. Pass the array to the component. If the SKU has no entry in SKU_FEATURES, all its feature cells fall back to false (× icon).

  1. Add a tooltip in FEATURE_TOOLTIPS:
'my_feature' => 'Plain-English explanation shown on hover/focus.'
  1. Add a display name in feature_display_name:
when 'my_feature' then 'My Feature Label'
  1. Add the value for every relevant SKU in SKU_FEATURES.

  2. Include 'my_feature' in the features: array on the calling page.


The SCSS (.product-compare-table) defines two complementary rules:

.feature-cell {
display: flex;
align-items: center;
justify-content: center; // product value cells: horizontally centred
text-align: center;
}
.sticky-column {
justify-content: flex-start; // overrides feature-cell — label column always left
text-align: left;
}

Without the .sticky-column override, short single-line labels (e.g. “WiFi-Enabled”) would appear visually centred while long wrapping labels (e.g. “Remote Troubleshooting / Updates”) appeared left — an inconsistency caused by flex centering a short inline text node.


  • The table-scroll-container uses overflow-x: auto — the table scrolls horizontally on narrow viewports rather than collapsing columns.
  • A dynamic min-width inline style (200 + products.size × 160 px) is applied to the inner wrapper to prevent columns from becoming unreadably narrow before the scroll kicks in.
  • The sticky label column (position: sticky; left: 0) remains visible as the user scrolls right, keeping feature labels always in view.
  • min-width of the sticky column scales down at tablet / compact-laptop breakpoints: 240px200px180px160px.

/floor-heating/thermostats

#Section IDComponent / Content
1heroWww::FullWidthLandingPageHeaderComponent
2benefitsWww::BenefitsListComponent — 4 universal thermostat guarantees
3comparisonWww::ProductCompareTableComponent — thermostats + nJoin module
4accessoriesWww::ProductCardsComponent — sensor + 3 rough-in kits
5guide”Which thermostat is right for you?” — 3 editorial cards
6shop-by-systemInternal cross-reference links (floor type / room / system)
7showcasesWww::ShowcaseGridComponent — conditional on CMS content
8videosWww::VideoSectionComponent — conditional on CMS content
9documentsWww::CardGridComponent — conditional on CMS content
10postsWww::CardGridComponent — conditional on CMS content
11faqWww::FaqListComponent — conditional on CMS content
12contactlazy_lead_form_modal
# US market
tstat_skus = %w[UWG5-4999-WY UDG4-4999 UTN5-4999 USG5-4000]
accessory_skus = %w[FH-BACKUP-SENSOR FHE-ROUGH-IN-KIT-S1 FHE-ROUGH-IN-KIT-S2 FHE-ROUGH-IN-KIT-S3]
# Canada
tstat_skus = %w[UWG5-4999-WY UDG4-4999-WY UTN5-4999 USG-4000]
accessory_skus = %w[FH-BACKUP-SENSOR FHE-ROUGH-IN-KIT-S1 FHE-ROUGH-IN-KIT-S2 FHE-ROUGH-IN-KIT-S3]

The nJoin power module (USG5-4000 / USG-4000) appears last in the comparison table (sort_by_price: false preserves insertion order) because it is not a thermostat — it extends a paired thermostat’s capacity for high-load zones.

Cross-Reference Section (SEO / Anti-Cannibalization)

Section titled “Cross-Reference Section (SEO / Anti-Cannibalization)”

The #shop-by-system section provides 24 internal links grouped by:

  • By Floor Type (8 links) — tile, LVT, engineered wood, laminate, bamboo, nailed hardwood, concrete, carpet
  • By Room (8 links) — bathroom, kitchen, bedroom, living room, basement, shower, laundry/mudroom, sunroom
  • By System Type (5 links) — mats, cable, custom mats, Prodeso, underlayment

This signals to Google that /floor-heating/thermostats is about controls and the linked pages are the authoritative source for each floor type / room — preventing keyword overlap and reinforcing the topical hierarchy under /floor-heating.

The pattern mirrors towel-warmer/controls → /towel-warmer/* cross-references.


The component reads item.short_description from the product database and surfaces it as a muted subtitle beneath each product title in the comparison header:

tagline: presenter.item&.short_description.presence

The tagline row is omitted entirely when no product in the comparison has a short description, keeping the header clean for legacy SKUs.