Exit Intent
blockmarketingExit-intent dialog trio that fires once per session when the pointer leaves toward the browser chrome: an extended-trial offer, a monthly-notes newsletter capture, and a one-question exit survey.
Installation
$pnpm dlx uipkge-ng@latest add exit-intent$npx uipkge-ng@latest add exit-intent$yarn dlx uipkge-ng@latest add exit-intent$bunx uipkge-ng@latest add exit-intentManifest:
https://uipkge.dev/r/angular/exit-intent.jsonInstalls to:src/app/components/blocks/exit-intent/Variants
Loading interactive previews…
Loading interactive previews…
npm dependencies
Files installed (4)
src/app/components/blocks/exit-intent/exit-intent-newsletter.component.ts4.4 kB
// Exit-intent capture for the monthly notes rather than a discount, naming the last issue // so the ask is concrete. Fires once per session when the pointer leaves toward the browser // chrome. Port of the Vue/React block 1:1. import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, signal } from '@angular/core' import { Check, LucideAngularModule, Mail } from 'lucide-angular' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogHeaderComponent, UiDialogTitleComponent, } from '@/ui/dialog/dialog.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-exit-intent-newsletter, [ui-exit-intent-newsletter]', standalone: true, // The block renders its own root <div> (and the dialog portals to body): keep the host out of layout. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogHeaderComponent, UiDialogTitleComponent, UiInputComponent, UiSeparatorComponent, ], template: ` <div data-slot="exit-intent-newsletter"> <ui-dialog [open]="open()" (openChange)="open.set($event)"> <ui-dialog-content class="sm:max-w-md"> <div ui-dialog-header> <span ui-badge variant="secondary" class="w-fit gap-1.5"> <lucide-icon [img]="Mail" class="size-3" aria-hidden="true" /> Monthly notes </span> <h2 ui-dialog-title class="mt-3 text-xl leading-snug text-balance"> Not ready to trial? Take the writing instead </h2> <p ui-dialog-description class="leading-relaxed"> Last issue: “Why we version metric definitions instead of dashboards.” One email a month, no product announcements, unsubscribe in one click. </p> </div> <ui-separator /> <form (submit)="subscribe($event)"> <div class="flex gap-2"> <ui-input [value]="email()" (valueChange)="email.set($event)" type="email" required placeholder="[email protected]" autoComplete="email" aria-label="Email address" /> <button ui-button type="submit" class="shrink-0">Subscribe</button> </div> <p class="mt-2 min-h-5 text-xs" aria-live="polite"> @if (submitted()) { <span class="text-success inline-flex items-center gap-1.5"> <lucide-icon [img]="Check" class="size-3" aria-hidden="true" /> Check your inbox to confirm. </span> } @else { <span class="text-muted-foreground">8,400 subscribers, mostly data and finance engineers.</span> } </p> </form> </ui-dialog-content> </ui-dialog> </div> `, }) export class UiExitIntentNewsletterComponent implements OnInit, OnDestroy { protected readonly Check = Check protected readonly Mail = Mail @Input() storageKey = 'uipkge:exit-newsletter-seen' readonly open = signal(false) readonly email = signal('') readonly submitted = signal(false) private readonly onPointerOut = (event: MouseEvent): void => { // Top edge only: leaving sideways is usually the scrollbar or another window. if (event.relatedTarget || event.clientY > 4) return try { if (window.sessionStorage.getItem(this.storageKey) === '1') return window.sessionStorage.setItem(this.storageKey, '1') } catch { // Blocked storage: shows once more this page view, never in a loop. } this.open.set(true) document.removeEventListener('mouseout', this.onPointerOut) } ngOnInit(): void { document.addEventListener('mouseout', this.onPointerOut) } ngOnDestroy(): void { document.removeEventListener('mouseout', this.onPointerOut) } subscribe(event: Event): void { event.preventDefault() if (!this.email().trim()) return this.submitted.set(true) } }src/app/components/blocks/exit-intent/exit-intent-offer.component.ts3.7 kB
// Extended-trial offer fired once per session when the pointer leaves toward the browser // chrome (top edge only — sideways exits are usually the scrollbar or another window). // Port of the Vue/React block 1:1. import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, signal } from '@angular/core' import { ArrowRight, Clock, LucideAngularModule } from 'lucide-angular' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, } from '@/ui/dialog/dialog.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-exit-intent-offer, [ui-exit-intent-offer]', standalone: true, // The block renders its own root <div> (and the dialog portals to body): keep the host out of layout. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, UiSeparatorComponent, ], template: ` <div data-slot="exit-intent-offer"> <ui-dialog [open]="open()" (openChange)="open.set($event)"> <ui-dialog-content class="sm:max-w-md"> <div ui-dialog-header> <span ui-badge variant="secondary" class="w-fit gap-1.5"> <lucide-icon [img]="Clock" class="size-3" aria-hidden="true" /> Before you go </span> <h2 ui-dialog-title class="mt-3 text-xl leading-snug text-balance">Take 30 days instead of 14</h2> <p ui-dialog-description class="leading-relaxed"> A full close cycle fits in 30 days and 14 does not, which is the actual reason most trials stall. No card, no call, cancel from the dashboard. </p> </div> <ui-separator /> <ul class="text-muted-foreground space-y-1.5 text-sm"> <li>· Everything on the Business plan</li> <li>· Connect your real warehouse, read-only</li> <li>· Keep the definitions you author either way</li> </ul> <div ui-dialog-footer class="sm:justify-start"> <button ui-button> Start the 30-day trial <lucide-icon [img]="ArrowRight" class="ml-2 size-4" aria-hidden="true" /> </button> <button ui-button variant="ghost" (click)="open.set(false)">No thanks</button> </div> </ui-dialog-content> </ui-dialog> </div> `, }) export class UiExitIntentOfferComponent implements OnInit, OnDestroy { protected readonly ArrowRight = ArrowRight protected readonly Clock = Clock @Input() storageKey = 'uipkge:exit-intent-seen' readonly open = signal(false) private readonly onPointerOut = (event: MouseEvent): void => { if (event.relatedTarget || event.clientY > 4) return if (this.show()) document.removeEventListener('mouseout', this.onPointerOut) } ngOnInit(): void { document.addEventListener('mouseout', this.onPointerOut) } ngOnDestroy(): void { document.removeEventListener('mouseout', this.onPointerOut) } show(): boolean { try { if (window.sessionStorage.getItem(this.storageKey) === '1') return false window.sessionStorage.setItem(this.storageKey, '1') } catch { // Blocked storage: it can show once more this page view, never in a loop. } this.open.set(true) return true } }src/app/components/blocks/exit-intent/exit-intent-survey.component.ts5.2 kB
// One-question exit survey asking what the visitor did not find, with preset answers, an // optional free-text field, and a thank-you state that closes itself. Fires once per session // when the pointer leaves toward the browser chrome. Port of the Vue/React block 1:1. import { ChangeDetectionStrategy, Component, DestroyRef, Input, OnDestroy, OnInit, inject, signal } from '@angular/core' import { Check, LucideAngularModule } from 'lucide-angular' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogHeaderComponent, UiDialogTitleComponent, } from '@/ui/dialog/dialog.component' import { UiTextareaComponent } from '@/ui/textarea/textarea.component' import { UiToggleGroupComponent, UiToggleGroupItemComponent } from '@/ui/toggle-group/toggle-group.component' @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-exit-intent-survey, [ui-exit-intent-survey]', standalone: true, // The block renders its own root <div> (and the dialog portals to body): keep the host out of layout. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogHeaderComponent, UiDialogTitleComponent, UiTextareaComponent, UiToggleGroupComponent, UiToggleGroupItemComponent, ], template: ` <div data-slot="exit-intent-survey"> <ui-dialog [open]="open()" (openChange)="open.set($event)"> <ui-dialog-content class="sm:max-w-md"> @if (!sent()) { <div> <div ui-dialog-header> <span ui-badge variant="secondary" class="w-fit">One question</span> <h2 ui-dialog-title class="mt-3 text-xl leading-snug text-balance">What were you looking for?</h2> <p ui-dialog-description> It goes to the people who build the page. No follow-up email unless you ask for one. </p> </div> <ui-toggle-group type="single" [value]="choice()" variant="outline" size="sm" class="mt-5 flex-wrap justify-start" aria-label="Reason for leaving" (valueChange)="onChoiceChange($event)" > @for (option of options; track option) { <button ui-toggle-group-item [value]="option">{{ option }}</button> } </ui-toggle-group> <ui-textarea [value]="detail()" (valueChange)="detail.set($event)" class="mt-3" [rows]="3" placeholder="Anything more (optional)" label="Anything more (optional)" /> <div class="mt-4 flex items-center gap-2"> <button ui-button [disabled]="!choice()" (click)="submit()">Send</button> <button ui-button variant="ghost" (click)="open.set(false)">Skip</button> </div> </div> } @else { <div class="py-6 text-center" aria-live="polite"> <span class="bg-success/10 mx-auto flex size-10 items-center justify-center rounded-full"> <lucide-icon [img]="Check" class="text-success size-5" aria-hidden="true" /> </span> <p class="mt-3 text-sm font-medium">Thanks — that is genuinely useful.</p> </div> } </ui-dialog-content> </ui-dialog> </div> `, }) export class UiExitIntentSurveyComponent implements OnInit, OnDestroy { protected readonly Check = Check @Input() storageKey = 'uipkge:exit-survey-seen' readonly open = signal(false) readonly choice = signal('') readonly detail = signal('') readonly sent = signal(false) protected readonly options = [ 'Pricing was unclear', 'Missing an integration', 'Just browsing', 'Security questions', 'Something else', ] private sendTimer: ReturnType<typeof setTimeout> | undefined private readonly onPointerOut = (event: MouseEvent): void => { if (event.relatedTarget || event.clientY > 4) return try { if (window.sessionStorage.getItem(this.storageKey) === '1') return window.sessionStorage.setItem(this.storageKey, '1') } catch { // Blocked storage: shows once more this page view, never in a loop. } this.open.set(true) document.removeEventListener('mouseout', this.onPointerOut) } constructor() { inject(DestroyRef).onDestroy(() => clearTimeout(this.sendTimer)) } ngOnInit(): void { document.addEventListener('mouseout', this.onPointerOut) } ngOnDestroy(): void { document.removeEventListener('mouseout', this.onPointerOut) } onChoiceChange(value: string): void { this.choice.set(value ?? '') } submit(): void { if (!this.choice()) return this.sent.set(true) // Close on its own rather than making someone dismiss a thank-you. clearTimeout(this.sendTimer) this.sendTimer = setTimeout(() => this.open.set(false), 1400) } }src/app/components/blocks/exit-intent/index.ts0.2 kB
export { UiExitIntentNewsletterComponent } from './exit-intent-newsletter.component' export { UiExitIntentOfferComponent } from './exit-intent-offer.component' export { UiExitIntentSurveyComponent } from './exit-intent-survey.component'
Raw manifest:https://uipkge.dev/r/angular/exit-intent.json