DEV Community

Chetan Sanghani
Chetan Sanghani

Posted on

A self-cleaning Product Hunt teaser banner in Blazor WASM — 100 lines, auto-hides after launch, GA4-tracked

I'm launching SmartTaxCalc.in on Product Hunt on Tuesday, 14 July 2026. It's a 38-tool Blazor WebAssembly tax + finance calculator I've written about here before (the SEO/schema saga, and dropping mobile LCP from 6-8s to under 2s).

The Product Hunt launch algorithm heavily rewards products that arrive with a real coming-soon follower base — day-of upvotes correlate strongly with pre-launch "Notify me" clicks. My PH page started with 1 follower. I had 9 days to get to 50+.

The obvious answer: post on LinkedIn, ask friends, DM your network. All of that has ceilings (you can only ask a favor once). The non-obvious answer that has no ceiling: convert your own organic search traffic into PH followers automatically.

This is the ~100 lines of Blazor code that does that, plus the design decisions I made along the way. It's also self-cleaning — after the launch date, the banner disappears with no manual work required. Steal the pattern for your own launch.

The problem

SmartTaxCalc gets modest but real organic traffic — mostly from Google Search Console impressions on tax-season queries. That traffic is the warmest possible audience for a PH launch (they already found the site, they're in the target demo). But how do you route them to a PH page without:

  1. Disrupting the tax content (they came for a tax calculator, not a marketing pitch)
  2. Cannibalizing the existing tax-season banner (which drives users to /tax-calendar/ — a real retention lever)
  3. Leaving code debt after 14 July (a dead PH banner still on the site in September)
  4. Losing the dismiss preference across page navigations (SPA reality — no page refresh)

Those constraints ruled out a modal, a full-width interrupt, and a "hardcoded remove after launch" approach.

The design

Slim horizontal bar at the top of every page. Sits ABOVE the existing tax-season banner. PH-brand orange, different from the tax-season banner's yellow/red so both are visually distinguishable when stacked. Dismissible per-user via localStorage. Auto-hides after a hard kill date. GA4 events on click + dismiss for measurement.

Structurally, it mirrors the existing TaxSeasonBanner.razor conventions (same DOM shape, same a11y attributes) so the two components have consistent tab-order and screen-reader behavior when both are visible.

The component

Shared/ProductHuntTeaserBanner.razor:

@inject IJSRuntime JS

@if (Visible && !Dismissed)
{
    <aside class="ph-teaser-banner" role="complementary"
           aria-label="Product Hunt launch teaser">
        <div class="ph-teaser-banner-content">
            <span class="ph-teaser-banner-icon" aria-hidden="true">🚀</span>
            <span class="ph-teaser-banner-text">
                <strong>Launching on Product Hunt Tue 14 July</strong>
                <span class="ph-teaser-banner-sub">Follow to get notified when we go live.</span>
            </span>
            <a class="ph-teaser-banner-cta"
               href="https://www.producthunt.com/products/smarttaxcalc"
               target="_blank"
               rel="noopener"
               @onclick="OnCtaClicked">
                Notify me on PH →
            </a>
            <button class="ph-teaser-banner-close"
                    @onclick="OnDismissClicked"
                    aria-label="Dismiss Product Hunt teaser"
                    type="button">&times;</button>
        </div>
    </aside>
}

@code {
    // Kill date: banner stops showing after this instant regardless of dismissal state.
    // 2026-07-14 23:59 IST = 18:29 UTC — one full day AFTER launch so day-of visitors
    // see it, but 07-15 onwards is clean.
    private static readonly DateTime KillDateUtc =
        new DateTime(2026, 7, 14, 18, 29, 0, DateTimeKind.Utc);

    private const string DismissKey = "ph-teaser-dismissed-2026-07-14";

    private bool Visible;
    private bool Dismissed;

    protected override void OnInitialized()
    {
        // Server-side timing gate. If we're past the kill date, don't render at all —
        // no localStorage check needed, no re-render churn, no bytes shipped.
        Visible = DateTime.UtcNow < KillDateUtc;
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender && Visible)
        {
            try
            {
                var dismissed = await JS.InvokeAsync<string?>(
                    "localStorage.getItem", DismissKey);
                if (dismissed == "1")
                {
                    Dismissed = true;
                    StateHasChanged();
                }
            }
            catch
            {
                // localStorage unavailable (private mode, edge cases) — banner just shows.
                // Failing "open" is better than failing "hidden" for a follower-growth tool.
            }
        }
    }

    private async Task OnCtaClicked()
    {
        try
        {
            await JS.InvokeVoidAsync("trackEvent", "ph_teaser_cta_clicked",
                new { launch = "2026-07-14" });
        }
        catch { }
    }

    private async Task OnDismissClicked()
    {
        Dismissed = true;
        try
        {
            await JS.InvokeVoidAsync("localStorage.setItem", DismissKey, "1");
            await JS.InvokeVoidAsync("trackEvent", "ph_teaser_dismissed",
                new { launch = "2026-07-14" });
        }
        catch { }
        StateHasChanged();
    }
}
Enter fullscreen mode Exit fullscreen mode

That's the whole thing. About 90 lines of C# + Razor including comments and whitespace.

Design decisions worth calling out

Kill-date is server-side, dismiss is client-side

The OnInitialized gate runs in Blazor's render tree before any HTML is emitted. After 2026-07-14 18:29 UTC, the whole <aside> never renders — no wasted bytes, no dead DOM, no need for the JS to check anything.

The dismiss check has to be client-side because it depends on the user's localStorage. So it lives in OnAfterRenderAsync and updates via StateHasChanged if the user previously dismissed.

This split matters. If I'd tried to do the kill-date check client-side too, I'd have shipped the banner HTML to every user post-launch, then hidden it with JS — wasteful and briefly visible during hydration. Server-side gate is free.

localStorage key is versioned by launch

private const string DismissKey = "ph-teaser-dismissed-2026-07-14";
Enter fullscreen mode Exit fullscreen mode

The -2026-07-14 suffix means my next PH launch (if I do a re-launch or feature launch later) uses a different key — users who dismissed the July teaser will still see the future teaser. No dismiss-forever bug.

Failing open, not closed

The try/catch around localStorage.getItem:

catch
{
    // localStorage unavailable (private mode, edge cases) — banner just shows.
}
Enter fullscreen mode Exit fullscreen mode

If localStorage throws (Safari private mode, ITP restrictions, some enterprise browsers), we DON'T hide the banner — we show it. For a follower-growth tool, false-positive display beats false-negative hiding.

Kill date uses UTC everywhere

The Cloudflare Pages server runs in UTC. The user's browser could be in any timezone. Blazor WASM runs in the browser but I'm doing the check at component initialization which happens after WASM boots — timezone at that point depends on how Blazor was invoked. Sticking with DateTime.UtcNow + a UTC kill-date instant sidesteps every possible drift.

The CSS — matching existing patterns

.ph-teaser-banner {
    background: linear-gradient(135deg, #ff6b35, #da552f);  /* PH brand orange */
    color: #ffffff;
    padding: 0.5rem 1rem;
    font-size: 0.9rem;
    line-height: 1.35;
    border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}
.ph-teaser-banner-content {
    max-width: 1200px;
    margin: 0 auto;
    display: flex;
    align-items: center;
    gap: 0.75rem;
    flex-wrap: wrap;
}
.ph-teaser-banner-cta {
    background: rgba(255, 255, 255, 0.15);
    color: #ffffff;
    font-weight: 700;
    padding: 0.35rem 0.75rem;
    border-radius: 4px;
    border: 1px solid rgba(255, 255, 255, 0.35);
    min-height: 32px;
    display: inline-flex;
    align-items: center;
}
.ph-teaser-banner-close {
    background: transparent;
    border: 0;
    color: inherit;
    font-size: 1.5rem;
    cursor: pointer;
    padding: 0 0.25rem;
    opacity: 0.75;
    min-width: 32px;
    min-height: 32px;
}
.dark-mode .ph-teaser-banner {
    background: linear-gradient(135deg, #c94820, #a83a1b);
}
Enter fullscreen mode Exit fullscreen mode

Two things worth noting:

32×32 minimum touch targets on the CTA and dismiss button (WCAG 2.5.5 — Success Criterion for touch target size). This is easy to forget on slim horizontal bars where the visual height is smaller. The button padding + min-height: 32px guarantees the tap area meets the standard.

Dark-mode variant because SmartTaxCalc has a full dark-mode toggle. Any new component that ships without a .dark-mode .foo variant looks jarring for the ~30% of users on dark mode.

Wiring into layout

Layout/MainLayout.razor — one line, above the existing tax-season banner:

<header class="app-header">...</header>

@* PH launch teaser — active 2026-07-05 → 2026-07-14 EOD IST *@
<ProductHuntTeaserBanner />

@* Tax-season-aware banner — May-Jul ITR, Dec advance tax, Mar year-end *@
<TaxSeasonBanner />

<main class="main-content">
    @Body
</main>
Enter fullscreen mode Exit fullscreen mode

Stacking order matters. PH banner is time-critical (9-day window), tax banner is a permanent retention CTA. Time-critical goes on top.

The cache-buster gotcha

Cloudflare Pages caches CSS aggressively. Deploying the new CSS with the same filename means returning users don't see the new styles until their browser cache expires (often hours or days).

Every CSS/JS reference on the site uses a query-string version:

<link href="/css/app.css?v=20260705a" rel="stylesheet" fetchpriority="high" />
<link rel="preload" href="/_framework/blazor.webassembly.js?v=20260620j" as="script" />
Enter fullscreen mode Exit fullscreen mode

On any deploy that ships new CSS, bump the v= string. I ran this one-liner to update every static HTML file in the wwwroot mirror:

perl -i -pe 's|app\.css\?v=20260703a|app.css?v=20260705a|g' `
  $(grep -rlE 'app\.css\?v=20260703a' frontend/web/TaxPlanner.Web/wwwroot/)
Enter fullscreen mode Exit fullscreen mode

Skipping this step meant users on returning-visit cache would see the banner in DOM (Blazor renders it) but with no styles — invisible text/button on a normal background. Nothing worse than a launch banner that only works for first-visit users.

The telemetry

Two GA4 events I care about:

  • ph_teaser_cta_clicked — fires when the user clicks "Notify me". This is the metric that matters. It correlates directly with PH follower growth.
  • ph_teaser_dismissed — fires when the user clicks the ×. If this is >30% of impressions, the banner is annoying and I should tune it.

I don't track banner impressions separately — Blazor renders it if OnInitialized allows AND the user hasn't dismissed. Impression count = pageview count from GA4 minus ph_teaser_dismissed count minus post-kill-date pageviews.

Realistic conversion math

Here's the funnel math I set targets against:

  • Daily organic pageviews on SmartTaxCalc: ~50-80 (Indian tax queries)
  • Percent of pageviews that click Notify me: 2-5% (informed guess; will measure)
  • Expected daily new followers: 1-4 from the banner alone
  • Nine days of banner active pre-launch: 9-36 followers
  • Combined with LinkedIn + personal DMs: target 50+

50 pre-launch followers is the informal PH threshold for "you have a shot at Top 20 of the day." Below 20 and the launch is dead on arrival. Above 100 and you're guaranteed Top 10 barring a bad launch-day thumbnail.

What I skipped

  • Modal/interrupt version: kills UX for the tax content (users came for a calculator, not a launch pitch)
  • Cookie banner-style at page bottom: lower click-through, competes visually with the actual cookie consent banner
  • Homepage-only version: 60% of my traffic lands on deep pages (calculator URLs from Google), not the homepage
  • Manual removal after launch: not scalable if I do another launch later — kill-date automates the "remove" step

Two takeaways

1. Time-bounded UI needs a self-cleaning mechanism from day one. Every "temporary" banner I've seen without a kill date has ended up living on production long past its usefulness. The kill-date constant is 3 lines of code and saves you a manual removal PR.

2. Convert your own traffic before you buy other people's. LinkedIn ads, PH-influencer shoutouts, upvote-swap groups — all lower conversion than your own site visitors who already trust you. If you're launching anything, this component (or the equivalent in your stack) is the highest-leverage growth code you can ship.

The launch itself

If you use tax calculators or you're a founder / indie hacker who wants to follow the launch curve, click Notify me on our PH page. Launch is Tuesday 14 July 2026 at 12:01 AM PDT (12:31 PM IST).

If this banner pattern is useful, feel free to lift it. Blazor + Razor + a few lines of CSS is all it takes.


About me: I'm Chetan Sanghani — I build SmartTaxCalc, a free CA-reviewed Indian tax calculator platform. This is my third article on Dev.to in the "Building SmartTaxCalc" series. Previous posts:

Top comments (0)