diff --git a/demo/angular.json b/demo/angular.json
new file mode 100644
index 0000000..6350d38
--- /dev/null
+++ b/demo/angular.json
@@ -0,0 +1,34 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "demo": {
+ "projectType": "application",
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular-devkit/build-angular:application",
+ "options": {
+ "outputPath": "dist",
+ "index": "src/index.html",
+ "browser": "src/main.ts",
+ "polyfills": ["zone.js"],
+ "tsConfig": "tsconfig.app.json",
+ "styles": ["src/styles.scss"],
+ "scripts": [],
+ "preserveSymlinks": true
+ }
+ },
+ "serve": {
+ "builder": "@angular-devkit/build-angular:dev-server",
+ "options": {
+ "buildTarget": "demo:build"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/demo/package.json b/demo/package.json
new file mode 100644
index 0000000..3c9d483
--- /dev/null
+++ b/demo/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "flair-elements-ui-demo",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "start": "ng serve --port 4203",
+ "build": "ng build"
+ },
+ "dependencies": {
+ "@angular/animations": "^19.1.0",
+ "@angular/common": "^19.1.0",
+ "@angular/compiler": "^19.1.0",
+ "@angular/core": "^19.1.0",
+ "@angular/platform-browser": "^19.1.0",
+ "@angular/platform-browser-dynamic": "^19.1.0",
+ "@sda/flair-elements-ui": "file:../dist",
+ "@sda/base-ui": "file:../../base-ui/dist"
+ },
+ "devDependencies": {
+ "@angular-devkit/build-angular": "^19.1.0",
+ "@angular/cli": "^19.1.0",
+ "@angular/compiler-cli": "^19.1.0",
+ "typescript": "~5.5.0"
+ }
+}
diff --git a/demo/src/app.component.ts b/demo/src/app.component.ts
new file mode 100644
index 0000000..6eb3a3b
--- /dev/null
+++ b/demo/src/app.component.ts
@@ -0,0 +1,833 @@
+import { Component } from '@angular/core';
+import {
+ // Scroll & Progress
+ FlScrollProgressComponent,
+
+ // Backgrounds & Fields
+ FlParticleFieldComponent,
+ FlFlowFieldComponent,
+ FlMatrixRainComponent,
+ FlAuroraComponent,
+ FlVoronoiMeshComponent,
+ FlFractalLandscapeComponent,
+ FlAmbientLightComponent,
+ FlFloatingLayersComponent,
+
+ // Text Effects
+ FlGradientTextComponent,
+ FlTypewriterComponent,
+ FlTextShimmerComponent,
+ FlGlitchTextComponent,
+ FlScrambleRevealComponent,
+ FlCounterRollupComponent,
+
+ // Cards
+ FlTiltCardComponent,
+ FlHolographicCardComponent,
+ FlSpotlightCardComponent,
+
+ // Generative Art
+ FlBlobComponent,
+ FlGenerativePatternComponent,
+ FlGradientMeshComponent,
+
+ // Dividers
+ FlWaveDividerComponent,
+ FlGradientDividerComponent,
+ FlGlowDividerComponent,
+
+ // Celebrations
+ FlConfettiComponent,
+ FlFireworksComponent,
+ FlSparkleComponent,
+ FlEmojiRainComponent,
+ FlCheckmarkFlourishComponent,
+
+ // Patterns & Textures
+ FlDotGridComponent,
+ FlBlueprintGridComponent,
+ FlTopographicLinesComponent,
+ FlCircuitBoardComponent,
+
+ // Status Indicators
+ FlBreathingDotComponent,
+ FlLiveIndicatorComponent,
+ FlSignalStrengthComponent,
+ FlHeartbeatLineComponent,
+
+ // Directives
+ FlHoverLiftDirective,
+ FlMagneticDirective,
+ FlRippleDirective,
+ FlNeonGlowDirective,
+ FlCursorGlowDirective,
+ FlRevealOnScrollDirective,
+ FlPressSquishDirective,
+ FlTextGradientDirective,
+} from '@sda/flair-elements-ui';
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [
+ // Scroll & Progress
+ FlScrollProgressComponent,
+
+ // Backgrounds & Fields
+ FlParticleFieldComponent,
+ FlFlowFieldComponent,
+ FlMatrixRainComponent,
+ FlAuroraComponent,
+ FlVoronoiMeshComponent,
+ FlFractalLandscapeComponent,
+ FlAmbientLightComponent,
+
+ // Text Effects
+ FlGradientTextComponent,
+ FlTypewriterComponent,
+ FlTextShimmerComponent,
+ FlGlitchTextComponent,
+ FlScrambleRevealComponent,
+ FlCounterRollupComponent,
+
+ // Cards
+ FlTiltCardComponent,
+ FlHolographicCardComponent,
+ FlSpotlightCardComponent,
+
+ // Generative Art
+ FlBlobComponent,
+ FlGenerativePatternComponent,
+ FlGradientMeshComponent,
+
+ // Dividers
+ FlWaveDividerComponent,
+ FlGradientDividerComponent,
+ FlGlowDividerComponent,
+
+ // Celebrations
+ FlConfettiComponent,
+ FlFireworksComponent,
+ FlSparkleComponent,
+ FlEmojiRainComponent,
+ FlCheckmarkFlourishComponent,
+
+ // Patterns & Textures
+ FlDotGridComponent,
+ FlBlueprintGridComponent,
+ FlTopographicLinesComponent,
+ FlCircuitBoardComponent,
+
+ // Status Indicators
+ FlBreathingDotComponent,
+ FlLiveIndicatorComponent,
+ FlSignalStrengthComponent,
+ FlHeartbeatLineComponent,
+
+ // Directives
+ FlHoverLiftDirective,
+ FlMagneticDirective,
+ FlRippleDirective,
+ FlNeonGlowDirective,
+ FlCursorGlowDirective,
+ FlRevealOnScrollDirective,
+ FlPressSquishDirective,
+ FlTextGradientDirective,
+ ],
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+ Flair Elements UI
+
+
+
+
+
+
+
+
+
+
+ Interactive Cards
+
+
+
+
Tilt Card
+
3D tilt effect that follows your cursor
+
+
+
+
+
+
Holographic Card
+
Iridescent holographic shimmer effect
+
+
+
+
+
+
Spotlight Card
+
Dynamic spotlight that follows your mouse
+
+
+
+
+
+
+
+
+
+ Text Effects
+
+
+
+
+ Beautiful Gradients
+
+
+
+
+
+ Shimmering Effect
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Canvas Backgrounds
+
+
+
+ Flow Field
+
+
+
+
+ Matrix Rain
+
+
+
+
+ Aurora
+
+
+
+
+ Voronoi Mesh
+
+
+
+
+ Fractal Landscape
+
+
+
+
+
+
+
+
+ Generative Art
+
+
+
+ Blob
+
+
+
+
+ Generative Pattern
+
+
+
+
+ Gradient Mesh
+
+
+
+
+
+
+
+
+ Celebrations
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (triggerConfetti) {
+
+ }
+ @if (triggerFireworks) {
+
+ }
+ @if (triggerSparkle) {
+
+ }
+ @if (triggerEmoji) {
+
+ }
+ @if (triggerCheckmark) {
+
+ }
+
+
+
+
+
+
+ Interactive Directives
+
+
+
Hover Lift
+
Elevates on hover
+
+
+
+
Magnetic
+
Attracted to cursor
+
+
+
+
Ripple
+
Click for ripple effect
+
+
+
+
Neon Glow
+
Glowing neon border
+
+
+
+
Cursor Glow
+
Glow follows cursor
+
+
+
+
Reveal on Scroll
+
Animates into view
+
+
+
+
Press Squish
+
Click to squish
+
+
+
+
Text Gradient
+
Gradient text directive
+
+
+
+
+
+
+
+
+ Patterns & Textures
+
+
+
+ Dot Grid
+
+
+
+
+ Blueprint Grid
+
+
+
+
+ Topographic Lines
+
+
+
+
+ Circuit Board
+
+
+
+
+
+
+
+
+ Status Indicators
+
+
+
+ Breathing Dot
+
+
+
+
+ Live Indicator
+
+
+
+
+ Signal Strength
+
+
+
+
+ Heartbeat Line
+
+
+
+
+
+
+
+
+ Ambient Effects
+
+ Ambient Light is active throughout this entire showcase,
+ creating subtle atmospheric effects in the background.
+
+
+
+
+
+ `,
+ styles: [`
+ :host {
+ display: block;
+ background: #0a0a0f;
+ color: #ffffff;
+ min-height: 100vh;
+ }
+
+ .showcase {
+ position: relative;
+ overflow-x: hidden;
+ }
+
+ /* Ambient Backgrounds */
+ .ambient-bg {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 0;
+ pointer-events: none;
+ opacity: 0.3;
+ }
+
+ /* Scroll Progress */
+ .scroll-progress {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ z-index: 1000;
+ }
+
+ /* Hero Section */
+ .hero {
+ position: relative;
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ }
+
+ .hero-particles {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ }
+
+ .hero-content {
+ position: relative;
+ z-index: 2;
+ text-align: center;
+ padding: 2rem;
+ max-width: 1200px;
+ }
+
+ .hero-title {
+ font-size: 5rem;
+ font-weight: 900;
+ margin-bottom: 2rem;
+ line-height: 1.2;
+ }
+
+ .hero-subtitle {
+ font-size: 1.5rem;
+ opacity: 0.9;
+ max-width: 800px;
+ margin: 0 auto;
+ }
+
+ /* Section Styles */
+ .section {
+ position: relative;
+ z-index: 1;
+ padding: 6rem 2rem;
+ max-width: 1400px;
+ margin: 0 auto;
+ }
+
+ .section-title {
+ font-size: 3rem;
+ font-weight: 800;
+ text-align: center;
+ margin-bottom: 4rem;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ }
+
+ /* Cards Grid */
+ .cards-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 2rem;
+ margin-bottom: 2rem;
+ }
+
+ .card-content {
+ padding: 3rem 2rem;
+ text-align: center;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 1rem;
+ backdrop-filter: blur(10px);
+ }
+
+ .card-content h3 {
+ font-size: 1.8rem;
+ margin-bottom: 1rem;
+ color: #fff;
+ }
+
+ .card-content p {
+ font-size: 1rem;
+ opacity: 0.8;
+ line-height: 1.6;
+ }
+
+ /* Text Effects */
+ .text-effects {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+ max-width: 800px;
+ margin: 0 auto;
+ }
+
+ .text-effect-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 2rem;
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 1rem;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ }
+
+ .text-effect-item label {
+ font-weight: 600;
+ font-size: 1.1rem;
+ opacity: 0.7;
+ min-width: 150px;
+ }
+
+ /* Canvas Grid */
+ .canvas-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
+ gap: 2rem;
+ }
+
+ .canvas-box {
+ position: relative;
+ height: 300px;
+ border-radius: 1rem;
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(0, 0, 0, 0.5);
+ }
+
+ .canvas-label {
+ position: absolute;
+ bottom: 1rem;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(0, 0, 0, 0.8);
+ padding: 0.5rem 1.5rem;
+ border-radius: 2rem;
+ font-size: 0.9rem;
+ font-weight: 600;
+ z-index: 10;
+ backdrop-filter: blur(10px);
+ }
+
+ /* Generative Grid */
+ .generative-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
+ gap: 2rem;
+ }
+
+ .generative-box {
+ position: relative;
+ height: 350px;
+ border-radius: 1rem;
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(0, 0, 0, 0.5);
+ }
+
+ /* Celebrations */
+ .celebrations-section {
+ position: relative;
+ }
+
+ .celebrations {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1.5rem;
+ justify-content: center;
+ align-items: center;
+ padding: 2rem;
+ }
+
+ .celebration-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ z-index: 100;
+ }
+
+ .celebration-btn {
+ padding: 1rem 2rem;
+ font-size: 1.1rem;
+ font-weight: 600;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: none;
+ border-radius: 0.75rem;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
+ }
+
+ .celebration-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
+ }
+
+ /* Directives Grid */
+ .directives-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 2rem;
+ }
+
+ .directive-card {
+ padding: 2.5rem 2rem;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 1rem;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ }
+
+ .directive-card h4 {
+ font-size: 1.3rem;
+ margin-bottom: 0.75rem;
+ color: #fff;
+ }
+
+ .directive-card p {
+ font-size: 0.95rem;
+ opacity: 0.7;
+ line-height: 1.5;
+ }
+
+ .directive-card:hover {
+ background: rgba(255, 255, 255, 0.08);
+ border-color: rgba(255, 255, 255, 0.2);
+ }
+
+ /* Patterns Grid */
+ .patterns-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 2rem;
+ }
+
+ .pattern-box {
+ position: relative;
+ height: 300px;
+ border-radius: 1rem;
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(0, 0, 0, 0.5);
+ }
+
+ /* Status Grid */
+ .status-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 3rem;
+ padding: 2rem;
+ }
+
+ .status-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ padding: 2rem;
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 1rem;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ }
+
+ .status-item span {
+ font-size: 1rem;
+ font-weight: 600;
+ opacity: 0.8;
+ }
+
+ /* Final Section */
+ .final-section {
+ min-height: 50vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .ambient-note {
+ max-width: 700px;
+ text-align: center;
+ font-size: 1.2rem;
+ line-height: 1.8;
+ opacity: 0.8;
+ padding: 2rem;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 1rem;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ }
+
+ /* Footer */
+ .footer {
+ text-align: center;
+ padding: 4rem 2rem;
+ font-size: 1.5rem;
+ position: relative;
+ z-index: 1;
+ }
+
+ /* Responsive Design */
+ @media (max-width: 768px) {
+ .hero-title {
+ font-size: 3rem;
+ }
+
+ .hero-subtitle {
+ font-size: 1.2rem;
+ }
+
+ .section-title {
+ font-size: 2rem;
+ }
+
+ .cards-grid,
+ .canvas-grid,
+ .generative-grid,
+ .directives-grid,
+ .patterns-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .text-effect-item {
+ flex-direction: column;
+ gap: 1rem;
+ text-align: center;
+ }
+
+ .text-effect-item label {
+ min-width: auto;
+ }
+ }
+ `]
+})
+export class AppComponent {
+ triggerConfetti = false;
+ triggerFireworks = false;
+ triggerSparkle = false;
+ triggerEmoji = false;
+ triggerCheckmark = false;
+}
diff --git a/demo/src/index.html b/demo/src/index.html
new file mode 100644
index 0000000..2c23593
--- /dev/null
+++ b/demo/src/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ Flair Elements UI Demo
+
+
+
+
+
+
+
+
diff --git a/demo/src/main.ts b/demo/src/main.ts
new file mode 100644
index 0000000..de41a48
--- /dev/null
+++ b/demo/src/main.ts
@@ -0,0 +1,6 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import { AppComponent } from './app.component';
+
+bootstrapApplication(AppComponent, {
+ providers: []
+}).catch((err) => console.error(err));
diff --git a/demo/src/styles.scss b/demo/src/styles.scss
new file mode 100644
index 0000000..fc8e71c
--- /dev/null
+++ b/demo/src/styles.scss
@@ -0,0 +1,32 @@
+@use '@sda/flair-elements-ui/src/styles' as flair;
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ background: #0a0a0f;
+ color: #ffffff;
+ overflow-x: hidden;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ margin: 0;
+}
+
+a {
+ color: #0ea5e9;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+button {
+ cursor: pointer;
+ font-family: inherit;
+}
diff --git a/demo/tsconfig.app.json b/demo/tsconfig.app.json
new file mode 100644
index 0000000..7d7c716
--- /dev/null
+++ b/demo/tsconfig.app.json
@@ -0,0 +1,13 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/app",
+ "types": []
+ },
+ "files": [
+ "src/main.ts"
+ ],
+ "include": [
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/demo/tsconfig.json b/demo/tsconfig.json
new file mode 100644
index 0000000..1860915
--- /dev/null
+++ b/demo/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "outDir": "./dist/out-tsc",
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "sourceMap": true,
+ "declaration": false,
+ "experimentalDecorators": true,
+ "moduleResolution": "bundler",
+ "importHelpers": true,
+ "target": "ES2022",
+ "module": "ES2022",
+ "lib": [
+ "ES2022",
+ "dom"
+ ]
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/src/components/fl-ambient-light/fl-ambient-light.component.scss b/src/components/fl-ambient-light/fl-ambient-light.component.scss
index c789411..a1922ce 100644
--- a/src/components/fl-ambient-light/fl-ambient-light.component.scss
+++ b/src/components/fl-ambient-light/fl-ambient-light.component.scss
@@ -10,22 +10,18 @@
.fl-ambient-light__layer-2,
.fl-ambient-light__layer-3 {
position: absolute;
- inset: 0;
+ inset: -20%;
pointer-events: none;
- will-change: background;
}
.fl-ambient-light__layer-1 {
opacity: 0.5;
- filter: blur(60px);
}
.fl-ambient-light__layer-2 {
opacity: 0.4;
- filter: blur(80px);
}
.fl-ambient-light__layer-3 {
opacity: 0.3;
- filter: blur(100px);
}
diff --git a/src/components/fl-ambient-light/fl-ambient-light.component.ts b/src/components/fl-ambient-light/fl-ambient-light.component.ts
index 111ad73..b104c3a 100644
--- a/src/components/fl-ambient-light/fl-ambient-light.component.ts
+++ b/src/components/fl-ambient-light/fl-ambient-light.component.ts
@@ -48,7 +48,7 @@ export class FlAmbientLightComponent {
}
const id = `ambient-light-${Math.random().toString(36).slice(2)}`;
- this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
+ this.loopCleanup = this.animationLoop.registerWithElement(id, this.el.nativeElement, (delta) => this.tick(delta));
});
this.destroyRef.onDestroy(() => {
diff --git a/src/components/fl-aurora/fl-aurora.component.ts b/src/components/fl-aurora/fl-aurora.component.ts
index b99ea2e..e84a12b 100644
--- a/src/components/fl-aurora/fl-aurora.component.ts
+++ b/src/components/fl-aurora/fl-aurora.component.ts
@@ -82,7 +82,7 @@ export class FlAuroraComponent {
this.renderStaticState();
} else {
const id = `aurora-${Math.random().toString(36).slice(2)}`;
- this.loopCleanup = this.animationLoop.register(id, (_, elapsed) => this.tick(elapsed));
+ this.loopCleanup = this.animationLoop.registerWithElement(id, svg, (_, elapsed) => this.tick(elapsed));
}
this.ready.emit();
diff --git a/src/components/fl-confetti/fl-confetti.component.ts b/src/components/fl-confetti/fl-confetti.component.ts
index 5a754e5..2627702 100644
--- a/src/components/fl-confetti/fl-confetti.component.ts
+++ b/src/components/fl-confetti/fl-confetti.component.ts
@@ -59,14 +59,14 @@ export class FlConfettiComponent {
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
- });
- // Watch for active changes
- effect(() => {
- const isActive = this.active();
- if (isActive && !this.isAnimating) {
- this.trigger();
- }
+ // Watch for active changes (must be after canvas is ready)
+ effect(() => {
+ const isActive = this.active();
+ if (isActive && !this.isAnimating) {
+ this.trigger();
+ }
+ });
});
this.destroyRef.onDestroy(() => {
diff --git a/src/components/fl-counter-rollup/fl-counter-rollup.component.ts b/src/components/fl-counter-rollup/fl-counter-rollup.component.ts
index b494f2e..f248595 100644
--- a/src/components/fl-counter-rollup/fl-counter-rollup.component.ts
+++ b/src/components/fl-counter-rollup/fl-counter-rollup.component.ts
@@ -1,6 +1,6 @@
import {
Component, ChangeDetectionStrategy, input, inject,
- afterNextRender, DestroyRef, signal, effect,
+ ElementRef, afterNextRender, DestroyRef, signal, effect,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@@ -13,6 +13,7 @@ import { ReducedMotionService } from '../../services/reduced-motion.service';
host: { 'class': 'fl-counter-rollup' },
})
export class FlCounterRollupComponent {
+ private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
@@ -32,24 +33,48 @@ export class FlCounterRollupComponent {
private targetValue = 0;
private startTime = 0;
private animationFrame: number | null = null;
+ private observer: IntersectionObserver | null = null;
+ private hasAnimated = false;
constructor() {
afterNextRender(() => {
- this.startAnimation();
+ if (this.reducedMotion.reduced()) {
+ this.targetValue = this.value();
+ this.currentValue = this.targetValue;
+ this.formattedValue.set(this.formatNumber(this.targetValue));
+ return;
+ }
+
+ this.targetValue = this.value();
+ this.observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting && !this.hasAnimated) {
+ this.observer?.disconnect();
+ this.observer = null;
+ this.hasAnimated = true;
+ this.startAnimation();
+ }
+ },
+ { threshold: 0.1 }
+ );
+ this.observer.observe(this.el.nativeElement);
});
effect(() => {
- // React to value changes
+ // React to value changes after initial animation
const newValue = this.value();
this.startValue = this.currentValue;
this.targetValue = newValue;
- this.startAnimation();
+ if (this.hasAnimated) {
+ this.startAnimation();
+ }
});
this.destroyRef.onDestroy(() => {
if (this.animationFrame !== null) {
cancelAnimationFrame(this.animationFrame);
}
+ this.observer?.disconnect();
});
}
diff --git a/src/components/fl-fireworks/fl-fireworks.component.ts b/src/components/fl-fireworks/fl-fireworks.component.ts
index c5b6606..ffa52bc 100644
--- a/src/components/fl-fireworks/fl-fireworks.component.ts
+++ b/src/components/fl-fireworks/fl-fireworks.component.ts
@@ -66,14 +66,14 @@ export class FlFireworksComponent {
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
- });
- // Watch for active changes
- effect(() => {
- const isActive = this.active();
- if (isActive && !this.isAnimating) {
- this.trigger();
- }
+ // Watch for active changes (must be after canvas is ready)
+ effect(() => {
+ const isActive = this.active();
+ if (isActive && !this.isAnimating) {
+ this.trigger();
+ }
+ });
});
this.destroyRef.onDestroy(() => {
diff --git a/src/components/fl-floating-layers/fl-floating-layers.component.ts b/src/components/fl-floating-layers/fl-floating-layers.component.ts
index fdcf895..5e3cbe7 100644
--- a/src/components/fl-floating-layers/fl-floating-layers.component.ts
+++ b/src/components/fl-floating-layers/fl-floating-layers.component.ts
@@ -37,7 +37,7 @@ export class FlFloatingLayersComponent {
if (this.reducedMotion.reduced()) return;
this.cursorCleanup = this.cursorTracker.subscribe();
const id = `floating-layers-${Math.random().toString(36).slice(2)}`;
- this.loopCleanup = this.animationLoop.register(id, () => this.tick());
+ this.loopCleanup = this.animationLoop.registerWithElement(id, this.el.nativeElement, () => this.tick());
});
this.destroyRef.onDestroy(() => {
diff --git a/src/components/fl-flow-field/fl-flow-field.component.ts b/src/components/fl-flow-field/fl-flow-field.component.ts
index 510695c..d8ef9a5 100644
--- a/src/components/fl-flow-field/fl-flow-field.component.ts
+++ b/src/components/fl-flow-field/fl-flow-field.component.ts
@@ -91,7 +91,7 @@ export class FlFlowFieldComponent {
this.ctx.fillRect(0, 0, this.width, this.height);
const id = `flow-field-${Math.random().toString(36).slice(2)}`;
- this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
+ this.loopCleanup = this.animationLoop.registerWithElement(id, canvas, (delta) => this.tick(delta));
this.ready.emit();
}
diff --git a/src/components/fl-fractal-landscape/fl-fractal-landscape.component.ts b/src/components/fl-fractal-landscape/fl-fractal-landscape.component.ts
index 9ae3dd5..a3ac393 100644
--- a/src/components/fl-fractal-landscape/fl-fractal-landscape.component.ts
+++ b/src/components/fl-fractal-landscape/fl-fractal-landscape.component.ts
@@ -76,7 +76,7 @@ export class FlFractalLandscapeComponent {
this.renderFrame(0);
} else {
const id = `fractal-${Math.random().toString(36).slice(2)}`;
- this.loopCleanup = this.animationLoop.register(id, (_, elapsed) => this.renderFrame(elapsed));
+ this.loopCleanup = this.animationLoop.registerWithElement(id, canvas, (_, elapsed) => this.renderFrame(elapsed));
}
this.ready.emit();
diff --git a/src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts b/src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts
index 732f1d9..9945f39 100644
--- a/src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts
+++ b/src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts
@@ -62,7 +62,7 @@ export class FlGradientMeshComponent {
}
const id = `gradient-mesh-${Math.random().toString(36).slice(2)}`;
- this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
+ this.loopCleanup = this.animationLoop.registerWithElement(id, this.el.nativeElement, (delta) => this.tick(delta));
});
this.destroyRef.onDestroy(() => {
diff --git a/src/components/fl-matrix-rain/fl-matrix-rain.component.ts b/src/components/fl-matrix-rain/fl-matrix-rain.component.ts
index 637205b..54b3061 100644
--- a/src/components/fl-matrix-rain/fl-matrix-rain.component.ts
+++ b/src/components/fl-matrix-rain/fl-matrix-rain.component.ts
@@ -81,7 +81,7 @@ export class FlMatrixRainComponent {
this.initColumns();
const id = `matrix-rain-${Math.random().toString(36).slice(2)}`;
- this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
+ this.loopCleanup = this.animationLoop.registerWithElement(id, canvas, (delta) => this.tick(delta));
this.ready.emit();
}
diff --git a/src/components/fl-particle-field/fl-particle-field.component.ts b/src/components/fl-particle-field/fl-particle-field.component.ts
index 2be6c0d..4eb5aea 100644
--- a/src/components/fl-particle-field/fl-particle-field.component.ts
+++ b/src/components/fl-particle-field/fl-particle-field.component.ts
@@ -108,7 +108,7 @@ export class FlParticleFieldComponent {
}
const id = `particle-field-${Math.random().toString(36).slice(2)}`;
- this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
+ this.loopCleanup = this.animationLoop.registerWithElement(id, canvas, (delta) => this.tick(delta));
this.ready.emit();
}
@@ -192,21 +192,68 @@ export class FlParticleFieldComponent {
if (p.y > h + p.size) p.y = -p.size;
}
- // Draw connections
+ // Draw connections using spatial grid
if (useConnections) {
+ const cellSize = connDist;
+ const gridCols = Math.ceil(w / cellSize) + 1;
+ const gridRows = Math.ceil(h / cellSize) + 1;
+ const grid = new Array(gridCols * gridRows);
+ for (let i = 0; i < grid.length; i++) grid[i] = [];
+
+ // Assign particles to grid cells
for (let i = 0; i < this.particles.length; i++) {
- for (let j = i + 1; j < this.particles.length; j++) {
- const a = this.particles[i];
- const b = this.particles[j];
- const d = distance2D(a.x, a.y, b.x, b.y);
- if (d < connDist) {
- const alpha = (1 - d / connDist) * connOpacity;
- ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`;
- ctx.lineWidth = 0.5;
- ctx.beginPath();
- ctx.moveTo(a.x, a.y);
- ctx.lineTo(b.x, b.y);
- ctx.stroke();
+ const p = this.particles[i];
+ const col = Math.floor(p.x / cellSize);
+ const row = Math.floor(p.y / cellSize);
+ if (col >= 0 && col < gridCols && row >= 0 && row < gridRows) {
+ grid[col * gridRows + row].push(i);
+ }
+ }
+
+ const maxConnsPerParticle = 5;
+ const connCounts = new Uint8Array(this.particles.length);
+ const drawn = new Set();
+
+ ctx.lineWidth = 0.5;
+
+ for (let col = 0; col < gridCols; col++) {
+ for (let row = 0; row < gridRows; row++) {
+ const cell = grid[col * gridRows + row];
+ if (cell.length === 0) continue;
+
+ // Check this cell and neighboring cells
+ for (let dc = -1; dc <= 1; dc++) {
+ for (let dr = -1; dr <= 1; dr++) {
+ const nc = col + dc;
+ const nr = row + dr;
+ if (nc < 0 || nc >= gridCols || nr < 0 || nr >= gridRows) continue;
+ const neighbor = grid[nc * gridRows + nr];
+
+ for (const i of cell) {
+ if (connCounts[i] >= maxConnsPerParticle) continue;
+ const a = this.particles[i];
+
+ for (const j of neighbor) {
+ if (j <= i) continue; // avoid duplicates
+ if (connCounts[j] >= maxConnsPerParticle) continue;
+
+ const b = this.particles[j];
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ const d = Math.sqrt(dx * dx + dy * dy);
+ if (d < connDist) {
+ const alpha = (1 - d / connDist) * connOpacity;
+ ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`;
+ ctx.beginPath();
+ ctx.moveTo(a.x, a.y);
+ ctx.lineTo(b.x, b.y);
+ ctx.stroke();
+ connCounts[i]++;
+ connCounts[j]++;
+ }
+ }
+ }
+ }
}
}
}
diff --git a/src/components/fl-scramble-reveal/fl-scramble-reveal.component.ts b/src/components/fl-scramble-reveal/fl-scramble-reveal.component.ts
index 815307e..f480da2 100644
--- a/src/components/fl-scramble-reveal/fl-scramble-reveal.component.ts
+++ b/src/components/fl-scramble-reveal/fl-scramble-reveal.component.ts
@@ -1,6 +1,6 @@
import {
Component, ChangeDetectionStrategy, input, inject,
- afterNextRender, DestroyRef, signal,
+ ElementRef, afterNextRender, DestroyRef, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@@ -13,6 +13,7 @@ import { ReducedMotionService } from '../../services/reduced-motion.service';
host: { 'class': 'fl-scramble-reveal' },
})
export class FlScrambleRevealComponent {
+ private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
@@ -28,6 +29,7 @@ export class FlScrambleRevealComponent {
private scrambleCount = 0;
private maxScrambles = 3;
private interval: any = null;
+ private observer: IntersectionObserver | null = null;
constructor() {
afterNextRender(() => {
@@ -36,13 +38,24 @@ export class FlScrambleRevealComponent {
return;
}
- this.startScramble();
+ this.observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting) {
+ this.observer?.disconnect();
+ this.observer = null;
+ this.startScramble();
+ }
+ },
+ { threshold: 0.1 }
+ );
+ this.observer.observe(this.el.nativeElement);
});
this.destroyRef.onDestroy(() => {
if (this.interval) {
clearInterval(this.interval);
}
+ this.observer?.disconnect();
});
}
diff --git a/src/components/fl-typewriter/fl-typewriter.component.ts b/src/components/fl-typewriter/fl-typewriter.component.ts
index c5b5582..d92107f 100644
--- a/src/components/fl-typewriter/fl-typewriter.component.ts
+++ b/src/components/fl-typewriter/fl-typewriter.component.ts
@@ -1,6 +1,6 @@
import {
Component, ChangeDetectionStrategy, input, inject,
- afterNextRender, DestroyRef, signal,
+ ElementRef, afterNextRender, DestroyRef, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@@ -18,6 +18,7 @@ import { ReducedMotionService } from '../../services/reduced-motion.service';
host: { 'class': 'fl-typewriter' },
})
export class FlTypewriterComponent {
+ private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
@@ -37,6 +38,7 @@ export class FlTypewriterComponent {
private typingInterval: any = null;
private cursorInterval: any = null;
private isTyping = false;
+ private observer: IntersectionObserver | null = null;
constructor() {
afterNextRender(() => {
@@ -45,12 +47,23 @@ export class FlTypewriterComponent {
return;
}
- setTimeout(() => this.startTyping(), this.delay());
- this.startCursorBlink();
+ this.observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting) {
+ this.observer?.disconnect();
+ this.observer = null;
+ setTimeout(() => this.startTyping(), this.delay());
+ this.startCursorBlink();
+ }
+ },
+ { threshold: 0.1 }
+ );
+ this.observer.observe(this.el.nativeElement);
});
this.destroyRef.onDestroy(() => {
this.cleanup();
+ this.observer?.disconnect();
});
}
diff --git a/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.ts b/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.ts
index 18181bc..38ad99d 100644
--- a/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.ts
+++ b/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.ts
@@ -52,6 +52,9 @@ export class FlVoronoiMeshComponent {
private pixelRatio = 1;
private loopCleanup: (() => void) | null = null;
private resizeObserver: ResizeObserver | null = null;
+ private cellCache: Uint8Array | null = null;
+ private lastSeedPositions: { x: number; y: number }[] = [];
+ private cacheStep = 4;
constructor() {
afterNextRender(() => {
@@ -87,7 +90,7 @@ export class FlVoronoiMeshComponent {
this.createSeeds();
const id = `voronoi-${Math.random().toString(36).slice(2)}`;
- this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
+ this.loopCleanup = this.animationLoop.registerWithElement(id, canvas, (delta) => this.tick(delta));
this.ready.emit();
}
@@ -106,6 +109,7 @@ export class FlVoronoiMeshComponent {
}
private createSeeds(): void {
+ this.cellCache = null;
const count = this.seedCount();
const cols = this.colors();
const spd = this.speed();
@@ -121,6 +125,17 @@ export class FlVoronoiMeshComponent {
}
}
+ private needsCacheInvalidation(): boolean {
+ if (!this.cellCache || this.lastSeedPositions.length !== this.seeds.length) return true;
+ const step = Math.max(4, Math.floor(6 / this.pixelRatio));
+ for (let i = 0; i < this.seeds.length; i++) {
+ const dx = this.seeds[i].x - this.lastSeedPositions[i].x;
+ const dy = this.seeds[i].y - this.lastSeedPositions[i].y;
+ if (dx * dx + dy * dy > step * step) return true;
+ }
+ return false;
+ }
+
private tick(delta: number): void {
if (!this.ctx) return;
@@ -145,55 +160,65 @@ export class FlVoronoiMeshComponent {
const ctx = this.ctx;
const w = this.width;
const h = this.height;
-
- // Use pixel-based approach for Voronoi (brute-force nearest-seed)
- // For performance, use a step size
- const step = Math.max(2, Math.floor(4 / this.pixelRatio));
+ const step = Math.max(4, Math.floor(6 / this.pixelRatio));
ctx.clearRect(0, 0, w, h);
- for (let x = 0; x < w; x += step) {
- for (let y = 0; y < h; y += step) {
- let minDist = Infinity;
- let nearestColor = this.seeds[0]?.color ?? '#000';
+ const cols = Math.ceil(w / step);
+ const rows = Math.ceil(h / step);
+ const totalCells = cols * rows;
- for (const s of this.seeds) {
- const d = (x - s.x) * (x - s.x) + (y - s.y) * (y - s.y);
- if (d < minDist) {
- minDist = d;
- nearestColor = s.color;
+ // Only recalculate cell assignments if seeds moved significantly
+ if (this.needsCacheInvalidation()) {
+ this.cellCache = new Uint8Array(totalCells);
+ this.cacheStep = step;
+
+ for (let col = 0; col < cols; col++) {
+ const x = col * step;
+ for (let row = 0; row < rows; row++) {
+ const y = row * step;
+ let minDist = Infinity;
+ let nearestIdx = 0;
+
+ for (let s = 0; s < this.seeds.length; s++) {
+ const dx = x - this.seeds[s].x;
+ const dy = y - this.seeds[s].y;
+ const d = dx * dx + dy * dy;
+ if (d < minDist) {
+ minDist = d;
+ nearestIdx = s;
+ }
}
- }
- ctx.fillStyle = nearestColor;
+ this.cellCache[col * rows + row] = nearestIdx;
+ }
+ }
+
+ this.lastSeedPositions = this.seeds.map(s => ({ x: s.x, y: s.y }));
+ }
+
+ // Render from cache
+ const cache = this.cellCache!;
+ for (let col = 0; col < cols; col++) {
+ const x = col * step;
+ for (let row = 0; row < rows; row++) {
+ const y = row * step;
+ const seedIdx = cache[col * rows + row];
+ ctx.fillStyle = this.seeds[seedIdx]?.color ?? '#000';
ctx.fillRect(x, y, step, step);
}
}
- // Draw edges using second-nearest distance comparison
+ // Draw edges only when showEdges is true
if (this.showEdges()) {
- ctx.strokeStyle = this.edgeColor();
- ctx.lineWidth = this.edgeWidth();
-
- for (let x = 0; x < w; x += step) {
- for (let y = 0; y < h; y += step) {
- let min1 = Infinity;
- let min2 = Infinity;
-
- for (const s of this.seeds) {
- const d = (x - s.x) * (x - s.x) + (y - s.y) * (y - s.y);
- if (d < min1) {
- min2 = min1;
- min1 = d;
- } else if (d < min2) {
- min2 = d;
- }
- }
-
- const ratio = min1 / min2;
- if (ratio > 0.95) {
- ctx.fillStyle = this.edgeColor();
- ctx.fillRect(x, y, step, step);
+ const edgeColor = this.edgeColor();
+ for (let col = 1; col < cols; col++) {
+ for (let row = 1; row < rows; row++) {
+ const idx = cache[col * rows + row];
+ // Check if neighboring cells have different seed assignments
+ if (idx !== cache[(col - 1) * rows + row] || idx !== cache[col * rows + (row - 1)]) {
+ ctx.fillStyle = edgeColor;
+ ctx.fillRect(col * step, row * step, step, step);
}
}
}
diff --git a/src/services/animation-loop.service.ts b/src/services/animation-loop.service.ts
index 917adf7..a2f293d 100644
--- a/src/services/animation-loop.service.ts
+++ b/src/services/animation-loop.service.ts
@@ -16,6 +16,10 @@ export class AnimationLoopService {
private startTime = 0;
private running = false;
+ /** Track which registered IDs are currently visible */
+ private visibilityMap = new Map();
+ private observers = new Map();
+
constructor() {
this.destroyRef.onDestroy(() => {
this.stop();
@@ -42,6 +46,37 @@ export class AnimationLoopService {
}
}
+ /** Register a callback tied to an element's visibility. Auto-pauses when off-screen. */
+ registerWithElement(id: string, element: Element, callback: (delta: number, elapsed: number) => void, priority = 0): () => void {
+ this.visibilityMap.set(id, false);
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ this.visibilityMap.set(id, entry.isIntersecting);
+ }
+ },
+ { threshold: 0 }
+ );
+ observer.observe(element);
+ this.observers.set(id, observer);
+
+ const wrappedCallback = (delta: number, elapsed: number): void => {
+ if (this.visibilityMap.get(id)) {
+ callback(delta, elapsed);
+ }
+ };
+
+ const baseCleanup = this.register(id, wrappedCallback, priority);
+
+ return () => {
+ baseCleanup();
+ observer.disconnect();
+ this.observers.delete(id);
+ this.visibilityMap.delete(id);
+ };
+ }
+
private start(): void {
if (this.running) return;
if (this.reducedMotion.reduced()) return;
diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss
index f071865..4edbb93 100644
--- a/src/styles/_mixins.scss
+++ b/src/styles/_mixins.scss
@@ -114,5 +114,5 @@
@keyframes fl-float {
0%, 100% { transform: translateY(0); }
- 50% { transform: translateY(-#{$distance}); }
+ 50% { transform: translateY(-10px); }
}