feat: add @sda/flair-elements-ui library

Angular 19+ standalone library with 61 components, 15 directives,
and 8 services for decorative visual effects, animations, and
micro-interactions.

Components include particle systems, flow fields, aurora effects,
tilt/holographic/spotlight cards, text effects, dividers, scroll
animations, generative art, celebrations, image treatments,
morphing shapes, terminal output, and pattern textures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Giuliano Silvestro
2026-03-09 10:24:52 +10:00
commit daf6182e94
230 changed files with 10642 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.fl-ambient-light__layer-1,
.fl-ambient-light__layer-2,
.fl-ambient-light__layer-3 {
position: absolute;
inset: 0;
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);
}

View File

@@ -0,0 +1,99 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
@Component({
selector: 'fl-ambient-light',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fl-ambient-light__layer-1" [style.background]="layer1()"></div>
<div class="fl-ambient-light__layer-2" [style.background]="layer2()"></div>
<div class="fl-ambient-light__layer-3" [style.background]="layer3()"></div>
`,
styleUrl: './fl-ambient-light.component.scss',
host: {
'class': 'fl-ambient-light',
},
})
export class FlAmbientLightComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly colors = input<string[]>(['#3b82f6', '#8b5cf6', '#ec4899']);
readonly speed = input(1);
readonly blur = input(100);
readonly opacity = input(0.5);
// Internal
private elapsed = 0;
private loopCleanup: (() => void) | null = null;
// Signals for layer backgrounds
readonly layer1 = signal('');
readonly layer2 = signal('');
readonly layer3 = signal('');
constructor() {
afterNextRender(() => {
if (this.reducedMotion.reduced()) {
this.setStaticGradients();
return;
}
const id = `ambient-light-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
});
}
private tick(delta: number): void {
this.elapsed += delta * this.speed();
this.updateGradients();
}
private updateGradients(): void {
const host = this.el.nativeElement as HTMLElement;
const width = host.offsetWidth;
const height = host.offsetHeight;
const colors = this.colors();
const blur = this.blur();
const opacity = this.opacity();
// Layer 1
const x1 = 50 + Math.sin(this.elapsed * 0.3) * 30;
const y1 = 50 + Math.cos(this.elapsed * 0.2) * 30;
const color1 = colors[0] || '#3b82f6';
this.layer1.set(`radial-gradient(circle at ${x1}% ${y1}%, ${color1} 0%, transparent ${blur}%)`);
// Layer 2
const x2 = 50 + Math.sin(this.elapsed * 0.4 + Math.PI) * 25;
const y2 = 50 + Math.cos(this.elapsed * 0.3 + Math.PI) * 25;
const color2 = colors[1] || '#8b5cf6';
this.layer2.set(`radial-gradient(circle at ${x2}% ${y2}%, ${color2} 0%, transparent ${blur}%)`);
// Layer 3
const x3 = 50 + Math.sin(this.elapsed * 0.5 + Math.PI * 0.5) * 20;
const y3 = 50 + Math.cos(this.elapsed * 0.4 + Math.PI * 0.5) * 20;
const color3 = colors[2] || '#ec4899';
this.layer3.set(`radial-gradient(circle at ${x3}% ${y3}%, ${color3} 0%, transparent ${blur}%)`);
}
private setStaticGradients(): void {
const colors = this.colors();
const blur = this.blur();
this.layer1.set(`radial-gradient(circle at 50% 50%, ${colors[0] || '#3b82f6'} 0%, transparent ${blur}%)`);
this.layer2.set(`radial-gradient(circle at 30% 70%, ${colors[1] || '#8b5cf6'} 0%, transparent ${blur}%)`);
this.layer3.set(`radial-gradient(circle at 70% 30%, ${colors[2] || '#ec4899'} 0%, transparent ${blur}%)`);
}
}

View File

@@ -0,0 +1 @@
export * from './fl-ambient-light.component';

View File

@@ -0,0 +1,46 @@
.fl-animated-border {
--fl-border-gradient: conic-gradient(from 0deg, #667eea, #764ba2, #f093fb, #4facfe, #667eea);
--fl-border-width: 2px;
--fl-border-speed: 3s;
display: inline-block;
position: relative;
padding: var(--fl-border-width);
background: var(--fl-border-gradient);
background-size: 200% 200%;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: var(--fl-border-width);
background: var(--fl-border-gradient);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
z-index: -1;
}
&--animated::before {
animation: fl-animated-border-rotate var(--fl-border-speed) linear infinite;
}
&__content {
position: relative;
background: inherit;
border-radius: inherit;
z-index: 1;
}
}
@keyframes fl-animated-border-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,56 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-animated-border',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fl-animated-border__content">
<ng-content />
</div>
`,
styleUrl: './fl-animated-border.component.scss',
host: { 'class': 'fl-animated-border' },
})
export class FlAnimatedBorderComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly colors = input<string[]>(['#667eea', '#764ba2', '#f093fb', '#4facfe']);
readonly width = input(2);
readonly speed = input(3);
readonly borderRadius = input('8px');
constructor() {
afterNextRender(() => {
this.updateStyles();
});
}
private updateStyles(): void {
const host = this.el.nativeElement as HTMLElement;
const colors = this.colors();
const width = this.width();
const speed = this.speed();
const borderRadius = this.borderRadius();
const gradient = `conic-gradient(from 0deg, ${colors.join(', ')}, ${colors[0]})`;
host.style.setProperty('--fl-border-gradient', gradient);
host.style.setProperty('--fl-border-width', `${width}px`);
host.style.setProperty('--fl-border-speed', `${speed}s`);
host.style.borderRadius = borderRadius;
if (this.reducedMotion.reduced()) {
host.classList.remove('fl-animated-border--animated');
} else {
host.classList.add('fl-animated-border--animated');
}
}
}

View File

@@ -0,0 +1 @@
export * from './fl-animated-border.component';

View File

@@ -0,0 +1,15 @@
:host {
display: block;
}
.fl-ascii-art__pre {
font-family: 'Courier New', Courier, monospace;
font-size: 0.75rem;
line-height: 1.2;
margin: 0;
padding: 1rem;
background: #000000;
border-radius: 0.25rem;
overflow-x: auto;
white-space: pre;
}

View File

@@ -0,0 +1,66 @@
import {
Component, ChangeDetectionStrategy, input, inject,
DestroyRef, afterNextRender, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-ascii-art',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<pre
class="fl-ascii-art__pre"
[class.fl-ascii-art__pre--reduced-motion]="reducedMotion.reduced()"
[style.color]="color()"
>{{ displayText() }}</pre>
`,
styleUrl: './fl-ascii-art.component.scss',
host: { 'class': 'fl-ascii-art' },
})
export class FlAsciiArtComponent {
protected reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly text = input('');
readonly color = input('#00ff00');
readonly animated = input(true);
readonly speed = input(20);
protected readonly displayText = signal('');
private currentIndex = 0;
private timerId: number | null = null;
constructor() {
afterNextRender(() => {
if (!this.animated() || this.reducedMotion.reduced()) {
this.displayText.set(this.text());
} else {
this.startAnimation();
}
});
this.destroyRef.onDestroy(() => {
if (this.timerId !== null) {
clearInterval(this.timerId);
}
});
}
private startAnimation(): void {
const fullText = this.text();
this.timerId = setInterval(() => {
if (this.currentIndex >= fullText.length) {
if (this.timerId !== null) {
clearInterval(this.timerId);
}
return;
}
this.displayText.set(fullText.slice(0, this.currentIndex + 1));
this.currentIndex++;
}, this.speed()) as unknown as number;
}
}

View File

@@ -0,0 +1 @@
export * from './fl-ascii-art.component';

View File

@@ -0,0 +1,20 @@
:host {
display: block;
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
::ng-deep > :not(svg) {
position: relative;
z-index: 1;
}
}

View File

@@ -0,0 +1,157 @@
import {
Component, ChangeDetectionStrategy, input, output, inject,
viewChild, ElementRef, afterNextRender, DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
import { createSvgElement, generateWavePath } from '../../utils/svg.utils';
@Component({
selector: 'fl-aurora',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg #svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<defs>
<filter id="aurora-blur" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="40" />
</filter>
</defs>
</svg>
<ng-content />
`,
styleUrl: './fl-aurora.component.scss',
host: { 'class': 'fl-aurora' },
})
export class FlAuroraComponent {
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
protected svgRef = viewChild.required<ElementRef<SVGElement>>('svg');
// Inputs
readonly colors = input<string[]>(['#22c55e', '#06b6d4', '#8b5cf6', '#6366f1']);
readonly layers = input(4);
readonly speed = input(1);
readonly blur = input(60);
readonly opacity = input(0.6);
readonly responsive = input(true);
// Outputs
readonly ready = output<void>();
private paths: SVGPathElement[] = [];
private width = 0;
private height = 0;
private loopCleanup: (() => void) | null = null;
private resizeObserver: ResizeObserver | null = null;
constructor() {
afterNextRender(() => {
this.initialize();
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
this.resizeObserver?.disconnect();
});
}
private initialize(): void {
const svg = this.svgRef().nativeElement;
const parent = svg.parentElement;
if (!parent) return;
this.resize();
if (this.responsive()) {
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(parent);
}
// Update blur
const blurFilter = svg.querySelector('feGaussianBlur');
if (blurFilter) {
blurFilter.setAttribute('stdDeviation', `${this.blur()}`);
}
this.createLayers();
if (this.reducedMotion.reduced()) {
this.renderStaticState();
} else {
const id = `aurora-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (_, elapsed) => this.tick(elapsed));
}
this.ready.emit();
}
private resize(): void {
const svg = this.svgRef().nativeElement;
const parent = svg.parentElement;
if (!parent) return;
this.width = parent.clientWidth;
this.height = parent.clientHeight;
svg.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`);
svg.setAttribute('width', `${this.width}`);
svg.setAttribute('height', `${this.height}`);
}
private createLayers(): void {
const svg = this.svgRef().nativeElement;
const colors = this.colors();
const layerCount = this.layers();
const opacity = this.opacity();
// Remove old paths
for (const p of this.paths) p.remove();
this.paths = [];
for (let i = 0; i < layerCount; i++) {
const path = createSvgElement('path', {
fill: colors[i % colors.length],
opacity: `${opacity * (1 - i * 0.15)}`,
filter: 'url(#aurora-blur)',
});
svg.appendChild(path);
this.paths.push(path);
}
}
private tick(elapsed: number): void {
const w = this.width;
const h = this.height;
const speed = this.speed();
const layerCount = this.layers();
for (let i = 0; i < this.paths.length; i++) {
const layerOffset = i / layerCount;
const amplitude = h * 0.15 * (1 + layerOffset * 0.5);
const frequency = 1.5 + layerOffset;
const phase = elapsed * speed * (0.3 + layerOffset * 0.2);
const yOffset = h * (0.2 + layerOffset * 0.15);
const d = generateWavePath(w, h, amplitude, frequency, phase, yOffset);
this.paths[i].setAttribute('d', d);
}
}
private renderStaticState(): void {
const w = this.width;
const h = this.height;
const layerCount = this.layers();
for (let i = 0; i < this.paths.length; i++) {
const layerOffset = i / layerCount;
const amplitude = h * 0.15 * (1 + layerOffset * 0.5);
const frequency = 1.5 + layerOffset;
const yOffset = h * (0.2 + layerOffset * 0.15);
const d = generateWavePath(w, h, amplitude, frequency, 0, yOffset);
this.paths[i].setAttribute('d', d);
}
}
}

View File

@@ -0,0 +1 @@
export * from './fl-aurora.component';

View File

@@ -0,0 +1,9 @@
:host {
display: inline-block;
}
.fl-blob__svg {
width: 100%;
height: 100%;
display: block;
}

View File

@@ -0,0 +1,95 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
import { generateBlobPath } from '../../utils/svg.utils';
@Component({
selector: 'fl-blob',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg
class="fl-blob__svg"
[attr.viewBox]="viewBox()"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient id="blob-gradient-{{id}}" x1="0%" y1="0%" x2="100%" y2="100%">
@for (color of colors(); track $index) {
<stop
[attr.offset]="($index / (colors().length - 1)) * 100 + '%'"
[attr.stop-color]="color"
/>
}
</linearGradient>
</defs>
<path
[attr.d]="blobPath()"
[attr.fill]="'url(#blob-gradient-' + id + ')'"
/>
</svg>
`,
styleUrl: './fl-blob.component.scss',
host: {
'class': 'fl-blob',
},
})
export class FlBlobComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly colors = input<string[]>(['#3b82f6', '#8b5cf6']);
readonly speed = input(1);
readonly complexity = input(8);
readonly size = input(200);
// Internal
private elapsed = 0;
private loopCleanup: (() => void) | null = null;
protected readonly id = `blob-${Math.random().toString(36).slice(2)}`;
// Signals
readonly blobPath = signal('');
readonly viewBox = signal('0 0 200 200');
constructor() {
afterNextRender(() => {
this.viewBox.set(`0 0 ${this.size()} ${this.size()}`);
if (this.reducedMotion.reduced()) {
this.updateBlob(0);
return;
}
const id = `blob-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
});
}
private tick(delta: number): void {
this.elapsed += delta * this.speed();
this.updateBlob(this.elapsed);
}
private updateBlob(phase: number): void {
const size = this.size();
const cx = size / 2;
const cy = size / 2;
const radius = size * 0.35;
const variance = size * 0.1;
const points = this.complexity();
const path = generateBlobPath(cx, cy, radius, points, variance, phase);
this.blobPath.set(path);
}
}

View File

@@ -0,0 +1 @@
export * from './fl-blob.component';

View File

@@ -0,0 +1,12 @@
:host {
display: block;
width: 100%;
height: 100%;
background: #0a1628;
}
.fl-blueprint-grid__svg {
display: block;
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,104 @@
import {
Component, ChangeDetectionStrategy, input, inject,
DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-blueprint-grid',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg
class="fl-blueprint-grid__svg"
[attr.width]="'100%'"
[attr.height]="'100%'"
preserveAspectRatio="xMidYMid slice"
>
<defs>
<pattern
[id]="patternId"
[attr.width]="spacing()"
[attr.height]="spacing()"
patternUnits="userSpaceOnUse"
>
<!-- Minor grid lines -->
<line
x1="0"
y1="0"
x2="0"
[attr.y2]="spacing()"
[attr.stroke]="color()"
stroke-width="0.5"
/>
<line
x1="0"
y1="0"
[attr.x2]="spacing()"
y2="0"
[attr.stroke]="color()"
stroke-width="0.5"
/>
</pattern>
<pattern
[id]="majorPatternId"
[attr.width]="spacing() * majorInterval()"
[attr.height]="spacing() * majorInterval()"
patternUnits="userSpaceOnUse"
>
<!-- Major grid lines -->
<rect
width="100%"
height="100%"
[attr.fill]="minorPatternFill"
/>
<line
x1="0"
y1="0"
x2="0"
[attr.y2]="spacing() * majorInterval()"
[attr.stroke]="majorColor()"
stroke-width="1"
/>
<line
x1="0"
y1="0"
[attr.x2]="spacing() * majorInterval()"
y2="0"
[attr.stroke]="majorColor()"
stroke-width="1"
/>
</pattern>
</defs>
<rect
width="100%"
height="100%"
[attr.fill]="majorPatternFill"
/>
</svg>
`,
styleUrl: './fl-blueprint-grid.component.scss',
host: { 'class': 'fl-blueprint-grid' },
})
export class FlBlueprintGridComponent {
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly spacing = input(20);
readonly color = input('rgba(100, 150, 200, 0.3)');
readonly majorInterval = input(5);
readonly majorColor = input('rgba(100, 150, 200, 0.6)');
protected readonly patternId = `blueprint-grid-${Math.random().toString(36).slice(2)}`;
protected readonly majorPatternId = `blueprint-major-${Math.random().toString(36).slice(2)}`;
protected get minorPatternFill(): string {
return `url(#${this.patternId})`;
}
protected get majorPatternFill(): string {
return `url(#${this.majorPatternId})`;
}
}

View File

@@ -0,0 +1 @@
export * from './fl-blueprint-grid.component';

View File

@@ -0,0 +1,28 @@
:host {
display: inline-block;
}
.fl-breathing-dot__inner {
border-radius: 50%;
animation: fl-breathing-dot-pulse 2s ease-in-out infinite;
box-shadow: 0 0 10px currentColor;
will-change: transform, box-shadow;
}
@keyframes fl-breathing-dot-pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 10px currentColor;
}
50% {
transform: scale(1.2);
box-shadow: 0 0 20px currentColor;
}
}
:host.fl-breathing-dot--reduced-motion {
.fl-breathing-dot__inner {
animation: none;
box-shadow: 0 0 10px currentColor;
}
}

View File

@@ -0,0 +1,37 @@
import {
Component, ChangeDetectionStrategy, input, inject, computed,
DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-breathing-dot',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="fl-breathing-dot__inner"
[style.width.px]="size()"
[style.height.px]="size()"
[style.background-color]="color()"
[style.animation-duration.s]="animationDuration()"
></div>
`,
styleUrl: './fl-breathing-dot.component.scss',
host: {
'class': 'fl-breathing-dot',
'[class.fl-breathing-dot--reduced-motion]': 'reducedMotion.reduced()',
},
})
export class FlBreathingDotComponent {
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly color = input('#3b82f6');
readonly size = input(12);
readonly speed = input(1);
// Computed
readonly animationDuration = computed(() => 2 / this.speed());
}

View File

@@ -0,0 +1 @@
export * from './fl-breathing-dot.component';

View File

@@ -0,0 +1,30 @@
:host {
display: block;
width: 100%;
height: 100%;
position: relative;
}
.fl-card-stack__container {
position: relative;
width: 100%;
height: 100%;
> * {
cursor: pointer;
}
}
:host.fl-card-stack--hover-expand {
.fl-card-stack__container > *:hover {
transform: translate(-50%, -50%) rotate(0deg) scale(1.05) !important;
z-index: 1000 !important;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
}
:host.fl-card-stack--reduced-motion {
.fl-card-stack__container > * {
transition: none !important;
}
}

View File

@@ -0,0 +1,62 @@
import {
Component, ChangeDetectionStrategy, input, inject,
DestroyRef, ElementRef, afterNextRender,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-card-stack',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fl-card-stack__container">
<ng-content />
</div>
`,
styleUrl: './fl-card-stack.component.scss',
host: {
'class': 'fl-card-stack',
'[class.fl-card-stack--reduced-motion]': 'reducedMotion.reduced()',
'[class.fl-card-stack--hover-expand]': 'hoverExpand()',
},
})
export class FlCardStackComponent {
protected reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
private el = inject(ElementRef);
// Inputs
readonly spread = input(20);
readonly rotation = input(3);
readonly hoverExpand = input(true);
constructor() {
afterNextRender(() => {
this.applyCardStyles();
});
}
private applyCardStyles(): void {
const container = this.el.nativeElement.querySelector('.fl-card-stack__container') as HTMLElement;
if (!container) return;
const children = Array.from(container.children) as HTMLElement[];
const total = children.length;
const spreadPx = this.spread();
const rotationDeg = this.rotation();
children.forEach((child, index) => {
const offset = (index - (total - 1) / 2);
const translateY = offset * spreadPx;
const rotate = offset * rotationDeg;
const zIndex = total - Math.abs(index - Math.floor(total / 2));
child.style.position = 'absolute';
child.style.top = '50%';
child.style.left = '50%';
child.style.transform = `translate(-50%, calc(-50% + ${translateY}px)) rotate(${rotate}deg)`;
child.style.zIndex = `${zIndex}`;
child.style.transition = 'transform 0.3s ease';
});
}
}

View File

@@ -0,0 +1 @@
export * from './fl-card-stack.component';

View File

@@ -0,0 +1,36 @@
:host {
display: inline-block;
position: relative;
}
.fl-checkmark-flourish__svg {
display: block;
}
.fl-checkmark-flourish__circle,
.fl-checkmark-flourish__checkmark {
transition: stroke-dashoffset 0.3s ease-in-out;
}
.fl-checkmark-flourish__sparkle {
position: absolute;
font-size: 1rem;
transform: translate(-50%, -50%);
pointer-events: none;
animation: fl-checkmark-sparkle 1s ease-out forwards;
}
@keyframes fl-checkmark-sparkle {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0);
}
50% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.2);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
}

View File

@@ -0,0 +1,181 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef, effect, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
@Component({
selector: 'fl-checkmark-flourish',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg
class="fl-checkmark-flourish__svg"
[attr.width]="size()"
[attr.height]="size()"
[attr.viewBox]="'0 0 ' + size() + ' ' + size()"
xmlns="http://www.w3.org/2000/svg"
>
<circle
[attr.cx]="size() / 2"
[attr.cy]="size() / 2"
[attr.r]="size() / 2 - 2"
[attr.stroke]="color()"
stroke-width="2"
fill="none"
[attr.stroke-dasharray]="circleCircumference()"
[attr.stroke-dashoffset]="circleOffset()"
class="fl-checkmark-flourish__circle"
/>
<path
[attr.d]="checkmarkPath()"
[attr.stroke]="color()"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
[attr.stroke-dasharray]="checkmarkLength()"
[attr.stroke-dashoffset]="checkmarkOffset()"
class="fl-checkmark-flourish__checkmark"
/>
</svg>
@if (showSparkles()) {
@for (sparkle of sparkles(); track $index) {
<div
class="fl-checkmark-flourish__sparkle"
[style.left]="sparkle.x + '%'"
[style.top]="sparkle.y + '%'"
[style.opacity]="sparkle.opacity"
>✨</div>
}
}
`,
styleUrl: './fl-checkmark-flourish.component.scss',
host: {
'class': 'fl-checkmark-flourish',
'[class.fl-checkmark-flourish--active]': 'active()',
},
})
export class FlCheckmarkFlourishComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly active = input(false);
readonly color = input('#10b981');
readonly size = input(64);
readonly duration = input(600);
// Internal
private progress = 0;
private loopCleanup: (() => void) | null = null;
private isAnimating = false;
// Signals
readonly circleOffset = signal(0);
readonly checkmarkOffset = signal(0);
readonly showSparkles = signal(false);
readonly sparkles = signal<Array<{ x: number; y: number; opacity: number }>>([]);
readonly circleCircumference = signal(0);
readonly checkmarkLength = signal(70);
readonly checkmarkPath = signal('');
constructor() {
afterNextRender(() => {
const size = this.size();
const radius = size / 2 - 2;
this.circleCircumference.set(2 * Math.PI * radius);
this.circleOffset.set(this.circleCircumference());
// Checkmark path
const scale = size / 64;
this.checkmarkPath.set(
`M ${18 * scale} ${32 * scale} L ${28 * scale} ${42 * scale} L ${46 * scale} ${24 * scale}`
);
this.checkmarkOffset.set(this.checkmarkLength());
// Watch for active changes
effect(() => {
const isActive = this.active();
if (isActive && !this.isAnimating) {
this.trigger();
} else if (!isActive) {
this.reset();
}
});
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
});
}
private trigger(): void {
if (this.reducedMotion.reduced()) {
this.circleOffset.set(0);
this.checkmarkOffset.set(0);
return;
}
this.progress = 0;
this.isAnimating = true;
const id = `checkmark-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
}
private tick(delta: number): void {
const durationSec = this.duration() / 1000;
this.progress += delta / durationSec;
if (this.progress >= 1) {
this.progress = 1;
this.isAnimating = false;
this.loopCleanup?.();
this.loopCleanup = null;
// Show sparkles
this.showSparkles.set(true);
this.createSparkles();
setTimeout(() => this.showSparkles.set(false), 1000);
}
// Animate circle (first half)
const circleProgress = Math.min(this.progress * 2, 1);
this.circleOffset.set(this.circleCircumference() * (1 - circleProgress));
// Animate checkmark (second half)
const checkmarkProgress = Math.max(0, (this.progress - 0.5) * 2);
this.checkmarkOffset.set(this.checkmarkLength() * (1 - checkmarkProgress));
}
private createSparkles(): void {
const sparkles = [];
for (let i = 0; i < 6; i++) {
const angle = (Math.PI * 2 * i) / 6;
const distance = 60;
sparkles.push({
x: 50 + Math.cos(angle) * distance,
y: 50 + Math.sin(angle) * distance,
opacity: 1,
});
}
this.sparkles.set(sparkles);
}
private reset(): void {
this.progress = 0;
this.circleOffset.set(this.circleCircumference());
this.checkmarkOffset.set(this.checkmarkLength());
this.showSparkles.set(false);
this.sparkles.set([]);
this.loopCleanup?.();
this.loopCleanup = null;
this.isAnimating = false;
}
}

View File

@@ -0,0 +1 @@
export * from './fl-checkmark-flourish.component';

View File

@@ -0,0 +1,12 @@
:host {
display: block;
width: 100%;
height: 100%;
background: #0a0a0a;
}
.fl-circuit-board__svg {
width: 100%;
height: 100%;
display: block;
}

View File

@@ -0,0 +1,157 @@
import {
Component, ChangeDetectionStrategy, input, inject,
DestroyRef, afterNextRender, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
interface CircuitPath {
d: string;
pulseOffset: number;
}
@Component({
selector: 'fl-circuit-board',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg
class="fl-circuit-board__svg"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid slice"
>
<defs>
<linearGradient [id]="pulseGradientId">
<stop offset="0%" [attr.stop-color]="color()" stop-opacity="0" />
<stop offset="50%" [attr.stop-color]="pulseColor()" stop-opacity="1" />
<stop offset="100%" [attr.stop-color]="color()" stop-opacity="0" />
</linearGradient>
</defs>
<!-- Circuit traces -->
@for (circuit of circuits(); track $index) {
<path
[attr.d]="circuit.d"
fill="none"
[attr.stroke]="color()"
stroke-width="0.5"
stroke-linecap="round"
/>
}
<!-- Animated pulses -->
@if (!reducedMotion.reduced()) {
@for (circuit of circuits(); track $index) {
<path
[attr.d]="circuit.d"
fill="none"
[attr.stroke]="pulseGradientFill"
stroke-width="1"
stroke-linecap="round"
[style.stroke-dasharray]="10"
[style.stroke-dashoffset]="getPulseOffset($index, circuit.pulseOffset)"
/>
}
}
<!-- Connection nodes -->
@for (node of nodes(); track $index) {
<circle
[attr.cx]="node.x"
[attr.cy]="node.y"
r="0.8"
[attr.fill]="pulseColor()"
/>
}
</svg>
`,
styleUrl: './fl-circuit-board.component.scss',
host: { 'class': 'fl-circuit-board' },
})
export class FlCircuitBoardComponent {
protected reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly color = input('#00ff88');
readonly pulseColor = input('#00ffff');
readonly speed = input(1);
readonly density = input(0.5);
protected readonly circuits = signal<CircuitPath[]>([]);
protected readonly nodes = signal<{ x: number; y: number }[]>([]);
protected readonly pulseGradientId = `circuit-pulse-${Math.random().toString(36).slice(2)}`;
private loopCleanup: (() => void) | null = null;
private time = 0;
protected get pulseGradientFill(): string {
return `url(#${this.pulseGradientId})`;
}
constructor() {
afterNextRender(() => {
this.generateCircuits();
if (!this.reducedMotion.reduced()) {
const id = `circuit-board-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
}
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
});
}
private tick(delta: number): void {
this.time += delta * this.speed();
}
protected getPulseOffset(index: number, baseOffset: number): number {
return (this.time * 50 + baseOffset) % 100;
}
private generateCircuits(): void {
const circuitPaths: CircuitPath[] = [];
const nodeList: { x: number; y: number }[] = [];
const pathCount = Math.floor(10 * this.density());
// Generate grid-based circuit paths
for (let i = 0; i < pathCount; i++) {
const startX = Math.random() * 100;
const startY = Math.random() * 100;
const path: string[] = [`M ${startX} ${startY}`];
let currentX = startX;
let currentY = startY;
const segments = 3 + Math.floor(Math.random() * 4);
for (let j = 0; j < segments; j++) {
const horizontal = Math.random() > 0.5;
const distance = 10 + Math.random() * 20;
if (horizontal) {
currentX += (Math.random() > 0.5 ? 1 : -1) * distance;
currentX = Math.max(0, Math.min(100, currentX));
} else {
currentY += (Math.random() > 0.5 ? 1 : -1) * distance;
currentY = Math.max(0, Math.min(100, currentY));
}
path.push(`L ${currentX} ${currentY}`);
nodeList.push({ x: currentX, y: currentY });
}
circuitPaths.push({
d: path.join(' '),
pulseOffset: Math.random() * 100,
});
}
this.circuits.set(circuitPaths);
this.nodes.set(nodeList);
}
}

View File

@@ -0,0 +1 @@
export * from './fl-circuit-board.component';

View File

@@ -0,0 +1,24 @@
:host {
display: block;
position: relative;
}
.fl-color-splash__content {
width: 100%;
height: 100%;
filter: grayscale(100%);
transition: filter 0.4s ease;
// Child elements with .fl-color-splash-keep class will keep their color
:global(.fl-color-splash-keep) {
filter: grayscale(0%);
}
&--active {
filter: grayscale(0%);
}
&--reduced-motion {
transition: none;
}
}

View File

@@ -0,0 +1,29 @@
import {
Component, ChangeDetectionStrategy, input, inject,
DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-color-splash',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="fl-color-splash__content"
[class.fl-color-splash__content--active]="active()"
[class.fl-color-splash__content--reduced-motion]="reducedMotion.reduced()"
>
<ng-content />
</div>
`,
styleUrl: './fl-color-splash.component.scss',
host: { 'class': 'fl-color-splash' },
})
export class FlColorSplashComponent {
protected reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly active = input(false);
}

View File

@@ -0,0 +1 @@
export * from './fl-color-splash.component';

View File

@@ -0,0 +1,14 @@
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
pointer-events: none;
}
.fl-confetti__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,153 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef, effect,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
interface ConfettiParticle {
x: number;
y: number;
vx: number;
vy: number;
rotation: number;
rotationSpeed: number;
color: string;
width: number;
height: number;
opacity: number;
life: number;
}
@Component({
selector: 'fl-confetti',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<canvas #canvas class="fl-confetti__canvas"></canvas>
`,
styleUrl: './fl-confetti.component.scss',
host: {
'class': 'fl-confetti',
},
})
export class FlConfettiComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly active = input(false);
readonly count = input(100);
readonly colors = input<string[]>(['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981']);
readonly duration = input(3000);
// Internal
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private particles: ConfettiParticle[] = [];
private loopCleanup: (() => void) | null = null;
private isAnimating = false;
constructor() {
afterNextRender(() => {
this.canvas = this.el.nativeElement.querySelector('canvas');
if (!this.canvas) return;
this.ctx = this.canvas.getContext('2d');
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
});
// Watch for active changes
effect(() => {
const isActive = this.active();
if (isActive && !this.isAnimating) {
this.trigger();
}
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
});
}
private resizeCanvas(): void {
if (!this.canvas) return;
const host = this.el.nativeElement as HTMLElement;
this.canvas.width = host.offsetWidth;
this.canvas.height = host.offsetHeight;
}
private trigger(): void {
if (this.reducedMotion.reduced() || !this.canvas) return;
this.particles = [];
const count = this.count();
const colors = this.colors();
const width = this.canvas.width;
const height = this.canvas.height;
for (let i = 0; i < count; i++) {
this.particles.push({
x: width / 2 + (Math.random() - 0.5) * 100,
y: height / 2,
vx: (Math.random() - 0.5) * 10,
vy: (Math.random() - 0.5) * 15 - 5,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.3,
color: colors[Math.floor(Math.random() * colors.length)],
width: 8 + Math.random() * 6,
height: 6 + Math.random() * 4,
opacity: 1,
life: this.duration() / 1000,
});
}
this.isAnimating = true;
const id = `confetti-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
}
private tick(delta: number): void {
if (!this.ctx || !this.canvas) return;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
let activeCount = 0;
for (const particle of this.particles) {
particle.x += particle.vx;
particle.y += particle.vy;
particle.vy += 0.3; // gravity
particle.rotation += particle.rotationSpeed;
particle.life -= delta;
particle.opacity = Math.max(0, particle.life / (this.duration() / 1000));
if (particle.life > 0) {
activeCount++;
this.drawParticle(particle);
}
}
if (activeCount === 0) {
this.isAnimating = false;
this.loopCleanup?.();
this.loopCleanup = null;
}
}
private drawParticle(particle: ConfettiParticle): void {
if (!this.ctx) return;
this.ctx.save();
this.ctx.translate(particle.x, particle.y);
this.ctx.rotate(particle.rotation);
this.ctx.globalAlpha = particle.opacity;
this.ctx.fillStyle = particle.color;
this.ctx.fillRect(-particle.width / 2, -particle.height / 2, particle.width, particle.height);
this.ctx.restore();
}
}

View File

@@ -0,0 +1 @@
export * from './fl-confetti.component';

View File

@@ -0,0 +1,4 @@
.fl-counter-rollup {
display: inline-block;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,99 @@
import {
Component, ChangeDetectionStrategy, input, inject,
afterNextRender, DestroyRef, signal, effect,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-counter-rollup',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `{{ prefix() }}{{ formattedValue() }}{{ suffix() }}`,
styleUrl: './fl-counter-rollup.component.scss',
host: { 'class': 'fl-counter-rollup' },
})
export class FlCounterRollupComponent {
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly value = input.required<number>();
readonly duration = input(2000);
readonly prefix = input('');
readonly suffix = input('');
readonly decimals = input(0);
readonly separator = input(',');
// Internal
readonly formattedValue = signal('0');
private currentValue = 0;
private startValue = 0;
private targetValue = 0;
private startTime = 0;
private animationFrame: number | null = null;
constructor() {
afterNextRender(() => {
this.startAnimation();
});
effect(() => {
// React to value changes
const newValue = this.value();
this.startValue = this.currentValue;
this.targetValue = newValue;
this.startAnimation();
});
this.destroyRef.onDestroy(() => {
if (this.animationFrame !== null) {
cancelAnimationFrame(this.animationFrame);
}
});
}
private startAnimation(): void {
if (this.reducedMotion.reduced()) {
this.currentValue = this.targetValue;
this.formattedValue.set(this.formatNumber(this.targetValue));
return;
}
this.startTime = performance.now();
this.animate();
}
private animate = (): void => {
const now = performance.now();
const elapsed = now - this.startTime;
const duration = this.duration();
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
this.currentValue = this.startValue + (this.targetValue - this.startValue) * eased;
this.formattedValue.set(this.formatNumber(this.currentValue));
if (progress < 1) {
this.animationFrame = requestAnimationFrame(this.animate);
} else {
this.currentValue = this.targetValue;
this.formattedValue.set(this.formatNumber(this.targetValue));
}
};
private formatNumber(value: number): string {
const decimals = this.decimals();
const separator = this.separator();
const fixed = value.toFixed(decimals);
const parts = fixed.split('.');
// Add thousands separator
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator);
return parts.join('.');
}
}

View File

@@ -0,0 +1 @@
export * from './fl-counter-rollup.component';

View File

@@ -0,0 +1,4 @@
.fl-counter-trigger {
display: inline-block;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,101 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { ScrollObserverService } from '../../services/scroll-observer.service';
@Component({
selector: 'fl-counter-trigger',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `{{ prefix() }}{{ formattedValue() }}{{ suffix() }}`,
styleUrl: './fl-counter-trigger.component.scss',
host: { 'class': 'fl-counter-trigger' },
})
export class FlCounterTriggerComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private scrollObserver = inject(ScrollObserverService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly value = input.required<number>();
readonly duration = input(2000);
readonly prefix = input('');
readonly suffix = input('');
// Internal
readonly formattedValue = signal('0');
private currentValue = 0;
private startValue = 0;
private targetValue = 0;
private startTime = 0;
private animationFrame: number | null = null;
private hasTriggered = false;
private observerCleanup: (() => void) | null = null;
constructor() {
afterNextRender(() => {
const host = this.el.nativeElement as HTMLElement;
this.observerCleanup = this.scrollObserver.observe(
host,
(entry) => {
if (entry.isIntersecting && !this.hasTriggered) {
this.hasTriggered = true;
this.startAnimation();
}
},
{ threshold: 0.5 }
);
});
this.destroyRef.onDestroy(() => {
this.observerCleanup?.();
if (this.animationFrame !== null) {
cancelAnimationFrame(this.animationFrame);
}
});
}
private startAnimation(): void {
this.startValue = 0;
this.targetValue = this.value();
if (this.reducedMotion.reduced()) {
this.currentValue = this.targetValue;
this.formattedValue.set(this.formatNumber(this.targetValue));
return;
}
this.startTime = performance.now();
this.animate();
}
private animate = (): void => {
const now = performance.now();
const elapsed = now - this.startTime;
const duration = this.duration();
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
this.currentValue = this.startValue + (this.targetValue - this.startValue) * eased;
this.formattedValue.set(this.formatNumber(this.currentValue));
if (progress < 1) {
this.animationFrame = requestAnimationFrame(this.animate);
} else {
this.currentValue = this.targetValue;
this.formattedValue.set(this.formatNumber(this.targetValue));
}
};
private formatNumber(value: number): string {
const fixed = Math.round(value).toString();
return fixed.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
}

View File

@@ -0,0 +1 @@
export * from './fl-counter-trigger.component';

View File

@@ -0,0 +1,11 @@
:host {
display: block;
width: 100%;
height: 100%;
}
.fl-dot-grid__svg {
display: block;
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,85 @@
import {
Component, ChangeDetectionStrategy, input, inject,
DestroyRef, afterNextRender, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
@Component({
selector: 'fl-dot-grid',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg
class="fl-dot-grid__svg"
[attr.width]="'100%'"
[attr.height]="'100%'"
preserveAspectRatio="xMidYMid slice"
>
<defs>
<pattern
[id]="patternId"
[attr.width]="spacing()"
[attr.height]="spacing()"
patternUnits="userSpaceOnUse"
>
<circle
[attr.cx]="spacing() / 2"
[attr.cy]="spacing() / 2"
[attr.r]="currentDotSize()"
[attr.fill]="color()"
/>
</pattern>
</defs>
<rect
width="100%"
height="100%"
[attr.fill]="patternFill"
/>
</svg>
`,
styleUrl: './fl-dot-grid.component.scss',
host: { 'class': 'fl-dot-grid' },
})
export class FlDotGridComponent {
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly dotSize = input(2);
readonly spacing = input(20);
readonly color = input('#cccccc');
readonly animated = input(false);
protected readonly currentDotSize = signal(2);
protected readonly patternId = `dot-grid-${Math.random().toString(36).slice(2)}`;
private loopCleanup: (() => void) | null = null;
private time = 0;
protected get patternFill(): string {
return `url(#${this.patternId})`;
}
constructor() {
afterNextRender(() => {
this.currentDotSize.set(this.dotSize());
if (this.animated() && !this.reducedMotion.reduced()) {
const id = `dot-grid-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
}
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
});
}
private tick(delta: number): void {
this.time += delta;
const pulse = Math.sin(this.time * 2) * 0.5 + 0.5;
this.currentDotSize.set(this.dotSize() * (0.8 + pulse * 0.4));
}
}

View File

@@ -0,0 +1 @@
export * from './fl-dot-grid.component';

View File

@@ -0,0 +1,16 @@
:host {
display: block;
position: relative;
}
.fl-duotone-filter__svg {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
.fl-duotone-filter__content {
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,72 @@
import {
Component, ChangeDetectionStrategy, input, inject,
DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-duotone-filter',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg class="fl-duotone-filter__svg" width="0" height="0">
<defs>
<filter [id]="filterId">
<feColorMatrix
type="matrix"
[attr.values]="colorMatrix"
/>
</filter>
</defs>
</svg>
<div class="fl-duotone-filter__content" [style.filter]="filterValue">
<ng-content />
</div>
`,
styleUrl: './fl-duotone-filter.component.scss',
host: { 'class': 'fl-duotone-filter' },
})
export class FlDuotoneFilterComponent {
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly colorLight = input('#ff6b6b');
readonly colorDark = input('#1a1a2e');
readonly intensity = input(1);
protected readonly filterId = `duotone-${Math.random().toString(36).slice(2)}`;
protected get colorMatrix(): string {
const light = this.hexToRgb(this.colorLight());
const dark = this.hexToRgb(this.colorDark());
const i = this.intensity();
// Create duotone effect by mapping luminance to two colors
const lr = (light.r - dark.r) / 255 * i;
const lg = (light.g - dark.g) / 255 * i;
const lb = (light.b - dark.b) / 255 * i;
return `
${lr} ${lr} ${lr} 0 ${dark.r / 255}
${lg} ${lg} ${lg} 0 ${dark.g / 255}
${lb} ${lb} ${lb} 0 ${dark.b / 255}
0 0 0 1 0
`.trim().replace(/\s+/g, ' ');
}
protected get filterValue(): string {
return `url(#${this.filterId})`;
}
private hexToRgb(hex: string): { r: number; g: number; b: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 0, b: 0 };
}
}

View File

@@ -0,0 +1 @@
export * from './fl-duotone-filter.component';

View File

@@ -0,0 +1,15 @@
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
}
.fl-emoji-rain__drop {
position: absolute;
font-size: 2rem;
will-change: transform, opacity;
pointer-events: none;
}

View File

@@ -0,0 +1,139 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef, effect, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
interface EmojiDrop {
id: string;
emoji: string;
x: number;
y: number;
vy: number;
rotation: number;
rotationSpeed: number;
scale: number;
opacity: number;
life: number;
}
@Component({
selector: 'fl-emoji-rain',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (drop of drops(); track drop.id) {
<div
class="fl-emoji-rain__drop"
[style.left.%]="drop.x"
[style.top.%]="drop.y"
[style.transform]="'translate(-50%, -50%) rotate(' + drop.rotation + 'deg) scale(' + drop.scale + ')'"
[style.opacity]="drop.opacity"
>{{ drop.emoji }}</div>
}
`,
styleUrl: './fl-emoji-rain.component.scss',
host: {
'class': 'fl-emoji-rain',
},
})
export class FlEmojiRainComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly active = input(false);
readonly emojis = input<string[]>(['🎉', '🎊', '🎈', '⭐', '✨']);
readonly count = input(30);
readonly duration = input(3000);
// Internal
private dropsData: EmojiDrop[] = [];
private loopCleanup: (() => void) | null = null;
private isAnimating = false;
// Signals
readonly drops = signal<EmojiDrop[]>([]);
constructor() {
afterNextRender(() => {
// Watch for active changes
effect(() => {
const isActive = this.active();
if (isActive && !this.isAnimating) {
this.trigger();
}
});
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
});
}
private trigger(): void {
if (this.reducedMotion.reduced()) return;
this.dropsData = [];
const count = this.count();
const emojis = this.emojis();
const durationSec = this.duration() / 1000;
for (let i = 0; i < count; i++) {
const delay = (Math.random() * durationSec * 0.3);
setTimeout(() => {
this.dropsData.push({
id: `emoji-${Date.now()}-${i}`,
emoji: emojis[Math.floor(Math.random() * emojis.length)],
x: Math.random() * 100,
y: -10,
vy: 15 + Math.random() * 10,
rotation: Math.random() * 360,
rotationSpeed: (Math.random() - 0.5) * 180,
scale: 0.5 + Math.random() * 0.5,
opacity: 1,
life: durationSec,
});
}, delay * 1000);
}
this.isAnimating = true;
const id = `emoji-rain-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
}
private tick(delta: number): void {
let activeCount = 0;
for (const drop of this.dropsData) {
drop.y += drop.vy * delta;
drop.rotation += drop.rotationSpeed * delta;
drop.life -= delta;
// Fade out at the end
if (drop.life < 0.5) {
drop.opacity = drop.life * 2;
}
// Remove if below viewport or life ended
if (drop.y < 110 && drop.life > 0) {
activeCount++;
}
}
// Update display
this.drops.set(
this.dropsData.filter(d => d.y < 110 && d.life > 0)
);
if (activeCount === 0 && this.dropsData.length >= this.count()) {
this.isAnimating = false;
this.drops.set([]);
this.loopCleanup?.();
this.loopCleanup = null;
}
}
}

View File

@@ -0,0 +1 @@
export * from './fl-emoji-rain.component';

View File

@@ -0,0 +1,14 @@
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
pointer-events: none;
}
.fl-fireworks__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,193 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef, effect,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
interface FireworkParticle {
x: number;
y: number;
vx: number;
vy: number;
color: string;
opacity: number;
life: number;
maxLife: number;
}
interface Firework {
x: number;
y: number;
targetY: number;
vy: number;
color: string;
exploded: boolean;
particles: FireworkParticle[];
}
@Component({
selector: 'fl-fireworks',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<canvas #canvas class="fl-fireworks__canvas"></canvas>
`,
styleUrl: './fl-fireworks.component.scss',
host: {
'class': 'fl-fireworks',
},
})
export class FlFireworksComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly active = input(false);
readonly count = input(5);
readonly colors = input<string[]>(['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981']);
readonly duration = input(3000);
// Internal
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private fireworks: Firework[] = [];
private loopCleanup: (() => void) | null = null;
private isAnimating = false;
constructor() {
afterNextRender(() => {
this.canvas = this.el.nativeElement.querySelector('canvas');
if (!this.canvas) return;
this.ctx = this.canvas.getContext('2d');
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
});
// Watch for active changes
effect(() => {
const isActive = this.active();
if (isActive && !this.isAnimating) {
this.trigger();
}
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
});
}
private resizeCanvas(): void {
if (!this.canvas) return;
const host = this.el.nativeElement as HTMLElement;
this.canvas.width = host.offsetWidth;
this.canvas.height = host.offsetHeight;
}
private trigger(): void {
if (this.reducedMotion.reduced() || !this.canvas) return;
this.fireworks = [];
const count = this.count();
const colors = this.colors();
const width = this.canvas.width;
const height = this.canvas.height;
for (let i = 0; i < count; i++) {
const delay = i * 300;
setTimeout(() => {
this.fireworks.push({
x: Math.random() * width,
y: height,
targetY: height * 0.2 + Math.random() * height * 0.3,
vy: -8 - Math.random() * 4,
color: colors[Math.floor(Math.random() * colors.length)],
exploded: false,
particles: [],
});
}, delay);
}
this.isAnimating = true;
const id = `fireworks-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
}
private tick(delta: number): void {
if (!this.ctx || !this.canvas) return;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
let activeCount = 0;
for (const firework of this.fireworks) {
if (!firework.exploded) {
firework.y += firework.vy;
firework.vy += 0.2; // gravity
// Draw rocket
this.ctx.fillStyle = firework.color;
this.ctx.fillRect(firework.x - 2, firework.y - 8, 4, 8);
if (firework.y <= firework.targetY) {
this.explode(firework);
}
activeCount++;
} else {
// Update and draw particles
let particleCount = 0;
for (const particle of firework.particles) {
particle.x += particle.vx;
particle.y += particle.vy;
particle.vy += 0.15; // gravity
particle.life -= delta;
particle.opacity = Math.max(0, particle.life / particle.maxLife);
if (particle.life > 0) {
particleCount++;
this.ctx.globalAlpha = particle.opacity;
this.ctx.fillStyle = particle.color;
this.ctx.fillRect(particle.x - 2, particle.y - 2, 4, 4);
}
}
if (particleCount > 0) {
activeCount++;
}
}
}
this.ctx.globalAlpha = 1;
if (activeCount === 0) {
this.isAnimating = false;
this.loopCleanup?.();
this.loopCleanup = null;
}
}
private explode(firework: Firework): void {
firework.exploded = true;
const particleCount = 50;
for (let i = 0; i < particleCount; i++) {
const angle = (Math.PI * 2 * i) / particleCount;
const speed = 2 + Math.random() * 3;
firework.particles.push({
x: firework.x,
y: firework.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
color: firework.color,
opacity: 1,
life: 1.5,
maxLife: 1.5,
});
}
}
}

View File

@@ -0,0 +1 @@
export * from './fl-fireworks.component';

View File

@@ -0,0 +1,17 @@
:host {
display: block;
position: relative;
overflow: hidden;
> ::ng-deep * {
will-change: transform;
transition: transform 50ms linear;
}
@media (prefers-reduced-motion: reduce) {
> ::ng-deep * {
transform: none !important;
transition: none !important;
}
}
}

View File

@@ -0,0 +1,79 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef, contentChildren,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { CursorTrackerService } from '../../services/cursor-tracker.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
import { lerp, mapRange, clamp } from '../../utils/math.utils';
@Component({
selector: 'fl-floating-layers',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<ng-content />`,
styleUrl: './fl-floating-layers.component.scss',
host: { 'class': 'fl-floating-layers' },
})
export class FlFloatingLayersComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private cursorTracker = inject(CursorTrackerService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly maxOffset = input(30);
readonly ease = input(0.05);
readonly inverted = input(false);
private currentX = 0;
private currentY = 0;
private cursorCleanup: (() => void) | null = null;
private loopCleanup: (() => void) | null = null;
constructor() {
afterNextRender(() => {
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.destroyRef.onDestroy(() => {
this.cursorCleanup?.();
this.loopCleanup?.();
});
}
private tick(): void {
const host = this.el.nativeElement as HTMLElement;
const rect = host.getBoundingClientRect();
const pos = this.cursorTracker.position();
const normX = mapRange(
clamp(pos.x, rect.left, rect.right),
rect.left, rect.right, -1, 1,
);
const normY = mapRange(
clamp(pos.y, rect.top, rect.bottom),
rect.top, rect.bottom, -1, 1,
);
const easeVal = this.ease();
this.currentX = lerp(this.currentX, normX, easeVal);
this.currentY = lerp(this.currentY, normY, easeVal);
const children = host.children;
const max = this.maxOffset();
const inv = this.inverted() ? -1 : 1;
for (let i = 0; i < children.length; i++) {
const child = children[i] as HTMLElement;
const depth = parseFloat(child.getAttribute('data-depth') ?? `${(i + 1) / children.length}`);
const offsetX = this.currentX * max * depth * inv;
const offsetY = this.currentY * max * depth * inv;
child.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
}
}
}

View File

@@ -0,0 +1 @@
export * from './fl-floating-layers.component';

View File

@@ -0,0 +1,14 @@
:host {
display: block;
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
background: #000;
canvas {
display: block;
width: 100%;
height: 100%;
}
}

View File

@@ -0,0 +1,196 @@
import {
Component, ChangeDetectionStrategy, input, output, inject,
viewChild, ElementRef, afterNextRender, DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
import { FL_CONFIG } from '../../providers/flair-config.provider';
import { getPixelRatio } from '../../utils/canvas.utils';
import { noise, randomInRange } from '../../utils/math.utils';
interface FlowParticle {
x: number;
y: number;
speed: number;
color: string;
}
@Component({
selector: 'fl-flow-field',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<canvas #canvas></canvas>`,
styleUrl: './fl-flow-field.component.scss',
host: { 'class': 'fl-flow-field' },
})
export class FlFlowFieldComponent {
private config = inject(FL_CONFIG);
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
protected canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
// Inputs
readonly particleCount = input(1000);
readonly speed = input(2);
readonly noiseScale = input(0.005);
readonly noiseSpeed = input(0.0005);
readonly fadeOpacity = input(0.03);
readonly colors = input<string[]>(['#6366f1', '#8b5cf6', '#a78bfa', '#c4b5fd']);
readonly lineWidth = input(1);
readonly responsive = input(true);
// Outputs
readonly ready = output<void>();
private particles: FlowParticle[] = [];
private ctx: CanvasRenderingContext2D | null = null;
private width = 0;
private height = 0;
private pixelRatio = 1;
private elapsed = 0;
private loopCleanup: (() => void) | null = null;
private resizeObserver: ResizeObserver | null = null;
constructor() {
afterNextRender(() => {
if (this.reducedMotion.reduced()) {
this.renderStaticFrame();
return;
}
this.initialize();
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
this.resizeObserver?.disconnect();
});
}
private initialize(): void {
const canvas = this.canvasRef().nativeElement;
this.ctx = canvas.getContext('2d');
if (!this.ctx) return;
this.pixelRatio = getPixelRatio(this.config.maxPixelRatio);
this.resize();
if (this.responsive()) {
this.resizeObserver = new ResizeObserver(() => {
this.resize();
this.createParticles();
});
this.resizeObserver.observe(canvas.parentElement!);
}
this.createParticles();
// Fill background
this.ctx.fillStyle = '#000';
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.ready.emit();
}
private resize(): void {
const canvas = this.canvasRef().nativeElement;
const parent = canvas.parentElement;
if (!parent) return;
this.width = parent.clientWidth;
this.height = parent.clientHeight;
canvas.width = this.width * this.pixelRatio;
canvas.height = this.height * this.pixelRatio;
canvas.style.width = `${this.width}px`;
canvas.style.height = `${this.height}px`;
this.ctx?.scale(this.pixelRatio, this.pixelRatio);
}
private createParticles(): void {
const count = this.particleCount();
const cols = this.colors();
this.particles = [];
for (let i = 0; i < count; i++) {
this.particles.push({
x: randomInRange(0, this.width),
y: randomInRange(0, this.height),
speed: randomInRange(0.5, 1.5),
color: cols[i % cols.length],
});
}
}
private tick(delta: number): void {
if (!this.ctx) return;
const w = this.width;
const h = this.height;
const ctx = this.ctx;
const scale = this.noiseScale();
const spd = this.speed();
const lw = this.lineWidth();
this.elapsed += delta;
// Fade previous frame
ctx.fillStyle = `rgba(0, 0, 0, ${this.fadeOpacity()})`;
ctx.fillRect(0, 0, w, h);
const timeOffset = this.elapsed * this.noiseSpeed() * 1000;
for (const p of this.particles) {
const angle = noise(p.x * scale, p.y * scale + timeOffset) * Math.PI * 4;
const prevX = p.x;
const prevY = p.y;
p.x += Math.cos(angle) * spd * p.speed * delta * 60;
p.y += Math.sin(angle) * spd * p.speed * delta * 60;
ctx.strokeStyle = p.color;
ctx.lineWidth = lw;
ctx.globalAlpha = 0.5;
ctx.beginPath();
ctx.moveTo(prevX, prevY);
ctx.lineTo(p.x, p.y);
ctx.stroke();
// Wrap around
if (p.x < 0 || p.x > w || p.y < 0 || p.y > h) {
p.x = randomInRange(0, w);
p.y = randomInRange(0, h);
}
}
ctx.globalAlpha = 1;
}
private renderStaticFrame(): void {
const canvas = this.canvasRef().nativeElement;
this.ctx = canvas.getContext('2d');
if (!this.ctx) return;
this.pixelRatio = getPixelRatio(this.config.maxPixelRatio);
this.resize();
this.createParticles();
this.ctx.fillStyle = '#000';
this.ctx.fillRect(0, 0, this.width, this.height);
// Draw static flow lines
const scale = this.noiseScale();
for (const p of this.particles) {
const angle = noise(p.x * scale, p.y * scale) * Math.PI * 4;
this.ctx.strokeStyle = p.color;
this.ctx.lineWidth = this.lineWidth();
this.ctx.globalAlpha = 0.3;
this.ctx.beginPath();
this.ctx.moveTo(p.x, p.y);
this.ctx.lineTo(p.x + Math.cos(angle) * 10, p.y + Math.sin(angle) * 10);
this.ctx.stroke();
}
this.ctx.globalAlpha = 1;
this.ready.emit();
}
}

View File

@@ -0,0 +1 @@
export * from './fl-flow-field.component';

View File

@@ -0,0 +1,19 @@
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
}
.fl-fluid-container__svg {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
.fl-fluid-container__content {
width: 100%;
height: 100%;
box-sizing: border-box;
}

View File

@@ -0,0 +1,125 @@
import {
Component, ChangeDetectionStrategy, input, inject,
DestroyRef, afterNextRender, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
@Component({
selector: 'fl-fluid-container',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg
class="fl-fluid-container__svg"
preserveAspectRatio="none"
[attr.viewBox]="viewBox"
>
<defs>
<clipPath [id]="clipId">
<path [attr.d]="clipPath()" />
</clipPath>
</defs>
</svg>
<div
class="fl-fluid-container__content"
[style.clip-path]="clipPathValue"
[style.border]="borderStyle"
>
<ng-content />
</div>
`,
styleUrl: './fl-fluid-container.component.scss',
host: { 'class': 'fl-fluid-container' },
})
export class FlFluidContainerComponent {
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly borderColor = input('#ff6b6b');
readonly speed = input(1);
readonly amplitude = input(10);
readonly borderWidth = input(2);
protected readonly clipPath = signal('');
protected readonly clipId = `fluid-${Math.random().toString(36).slice(2)}`;
protected readonly viewBox = '0 0 100 100';
private loopCleanup: (() => void) | null = null;
private time = 0;
protected get clipPathValue(): string {
return this.reducedMotion.reduced() ? 'none' : `url(#${this.clipId})`;
}
protected get borderStyle(): string {
return `${this.borderWidth()}px solid ${this.borderColor()}`;
}
constructor() {
afterNextRender(() => {
this.updatePath();
if (!this.reducedMotion.reduced()) {
const id = `fluid-container-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
}
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
});
}
private tick(delta: number): void {
this.time += delta * this.speed();
this.updatePath();
}
private updatePath(): void {
const amp = this.amplitude() / 10;
const t = this.time;
// Create undulating border effect
const topWave = this.createWave(0, 100, t, amp, 0);
const rightWave = this.createWave(100, 100, t, amp, Math.PI / 2, true);
const bottomWave = this.createWave(100, 0, t, amp, Math.PI);
const leftWave = this.createWave(0, 0, t, amp, 3 * Math.PI / 2, true);
this.clipPath.set(`
M ${topWave}
L ${rightWave}
L ${bottomWave}
L ${leftWave}
Z
`);
}
private createWave(
start: number,
end: number,
time: number,
amplitude: number,
phase: number,
vertical = false
): string {
const points: string[] = [];
const steps = 20;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const pos = start + (end - start) * t;
const wave = Math.sin(t * Math.PI * 4 + time + phase) * amplitude;
if (vertical) {
points.push(`${pos + wave},${start + (end - start) * t}`);
} else {
points.push(`${start + (end - start) * t},${pos + wave}`);
}
}
return points.join(' L ');
}
}

View File

@@ -0,0 +1 @@
export * from './fl-fluid-container.component';

View File

@@ -0,0 +1,14 @@
:host {
display: block;
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
background: #0a0a0a;
canvas {
display: block;
width: 100%;
height: 100%;
}
}

View File

@@ -0,0 +1,224 @@
import {
Component, ChangeDetectionStrategy, input, output, inject,
viewChild, ElementRef, afterNextRender, DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
import { FL_CONFIG } from '../../providers/flair-config.provider';
import { getPixelRatio } from '../../utils/canvas.utils';
import { randomInRange } from '../../utils/math.utils';
@Component({
selector: 'fl-fractal-landscape',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<canvas #canvas></canvas>`,
styleUrl: './fl-fractal-landscape.component.scss',
host: { 'class': 'fl-fractal-landscape' },
})
export class FlFractalLandscapeComponent {
private config = inject(FL_CONFIG);
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
protected canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
// Inputs
readonly gridSize = input(64);
readonly roughness = input(0.6);
readonly speed = input(0.3);
readonly wireColor = input('#6366f1');
readonly wireOpacity = input(0.6);
readonly perspective = input(400);
readonly elevation = input(0.4);
readonly rotationSpeed = input(0.1);
readonly responsive = input(true);
// Outputs
readonly ready = output<void>();
private heightmap: number[][] = [];
private ctx: CanvasRenderingContext2D | null = null;
private width = 0;
private height = 0;
private pixelRatio = 1;
private loopCleanup: (() => void) | null = null;
private resizeObserver: ResizeObserver | null = null;
constructor() {
afterNextRender(() => {
this.initialize();
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
this.resizeObserver?.disconnect();
});
}
private initialize(): void {
const canvas = this.canvasRef().nativeElement;
this.ctx = canvas.getContext('2d');
if (!this.ctx) return;
this.pixelRatio = getPixelRatio(this.config.maxPixelRatio);
this.resize();
if (this.responsive()) {
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas.parentElement!);
}
this.generateHeightmap();
if (this.reducedMotion.reduced()) {
this.renderFrame(0);
} else {
const id = `fractal-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (_, elapsed) => this.renderFrame(elapsed));
}
this.ready.emit();
}
private resize(): void {
const canvas = this.canvasRef().nativeElement;
const parent = canvas.parentElement;
if (!parent) return;
this.width = parent.clientWidth;
this.height = parent.clientHeight;
canvas.width = this.width * this.pixelRatio;
canvas.height = this.height * this.pixelRatio;
canvas.style.width = `${this.width}px`;
canvas.style.height = `${this.height}px`;
this.ctx?.scale(this.pixelRatio, this.pixelRatio);
}
/** Diamond-square heightmap generation */
private generateHeightmap(): void {
const size = this.gridSize() + 1;
const roughness = this.roughness();
this.heightmap = Array.from({ length: size }, () => new Array(size).fill(0));
// Seed corners
this.heightmap[0][0] = randomInRange(-1, 1);
this.heightmap[0][size - 1] = randomInRange(-1, 1);
this.heightmap[size - 1][0] = randomInRange(-1, 1);
this.heightmap[size - 1][size - 1] = randomInRange(-1, 1);
let step = size - 1;
let scale = roughness;
while (step > 1) {
const half = step >> 1;
// Diamond step
for (let y = 0; y < size - 1; y += step) {
for (let x = 0; x < size - 1; x += step) {
const avg = (
this.heightmap[y][x] +
this.heightmap[y][x + step] +
this.heightmap[y + step][x] +
this.heightmap[y + step][x + step]
) / 4;
this.heightmap[y + half][x + half] = avg + randomInRange(-scale, scale);
}
}
// Square step
for (let y = 0; y < size; y += half) {
for (let x = (y + half) % step; x < size; x += step) {
let sum = 0;
let count = 0;
if (y - half >= 0) { sum += this.heightmap[y - half][x]; count++; }
if (y + half < size) { sum += this.heightmap[y + half][x]; count++; }
if (x - half >= 0) { sum += this.heightmap[y][x - half]; count++; }
if (x + half < size) { sum += this.heightmap[y][x + half]; count++; }
this.heightmap[y][x] = sum / count + randomInRange(-scale, scale);
}
}
step = half;
scale *= 0.5;
}
}
private renderFrame(elapsed: number): void {
if (!this.ctx) return;
const ctx = this.ctx;
const w = this.width;
const h = this.height;
const size = this.gridSize() + 1;
const perspective = this.perspective();
const elevation = this.elevation();
const rotation = elapsed * this.rotationSpeed();
const color = this.wireColor();
const opacity = this.wireOpacity();
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = color;
ctx.globalAlpha = opacity;
ctx.lineWidth = 0.5;
const cx = w / 2;
const cy = h * 0.65;
const cosR = Math.cos(rotation);
const sinR = Math.sin(rotation);
const project = (gx: number, gy: number, gz: number): { x: number; y: number; z: number } => {
// Rotate around Y axis
const rx = gx * cosR - gz * sinR;
const rz = gx * sinR + gz * cosR;
const ry = gy;
// Perspective projection
const scale = perspective / (perspective + rz + size * 0.5);
return {
x: cx + rx * scale,
y: cy + ry * scale,
z: rz,
};
};
// Draw wireframe grid
for (let y = 0; y < size - 1; y++) {
for (let x = 0; x < size - 1; x++) {
const gx = (x - size / 2) * 4;
const gz = (y - size / 2) * 4;
const gy = -this.heightmap[y][x] * size * elevation;
const gx1 = ((x + 1) - size / 2) * 4;
const gz1 = ((y + 1) - size / 2) * 4;
const gy1x = -this.heightmap[y][x + 1] * size * elevation;
const gy1y = -this.heightmap[y + 1][x] * size * elevation;
const p0 = project(gx, gy, gz);
const p1 = project(gx1, gy1x, gz);
const p2 = project(gx, gy1y, gz1);
// Depth-based opacity
const depthAlpha = Math.max(0.1, 1 - (p0.z + size) / (size * 2));
ctx.globalAlpha = opacity * depthAlpha;
// Horizontal line
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.stroke();
// Vertical line
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
}
ctx.globalAlpha = 1;
}
}

View File

@@ -0,0 +1 @@
export * from './fl-fractal-landscape.component';

View File

@@ -0,0 +1,33 @@
:host {
display: inline-block;
width: 200px;
height: 200px;
}
.fl-frequency-rings__svg {
width: 100%;
height: 100%;
circle {
animation: fl-frequency-rings-pulse 2s ease-in-out infinite;
transform-origin: center;
will-change: transform, opacity;
}
}
@keyframes fl-frequency-rings-pulse {
0%, 100% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.15);
opacity: 0.3;
}
}
:host.fl-frequency-rings--reduced-motion {
.fl-frequency-rings__svg circle {
animation: none;
}
}

View File

@@ -0,0 +1,60 @@
import {
Component, ChangeDetectionStrategy, input, inject, computed,
DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-frequency-rings',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg class="fl-frequency-rings__svg" viewBox="0 0 200 200">
@for (ring of ringData(); track ring.index) {
<circle
cx="100"
cy="100"
[attr.r]="ring.radius"
fill="none"
[attr.stroke]="color()"
[attr.stroke-width]="ring.strokeWidth"
[attr.opacity]="ring.opacity"
[style.animation-duration.s]="ring.duration"
[style.animation-delay.s]="ring.delay"
/>
}
</svg>
`,
styleUrl: './fl-frequency-rings.component.scss',
host: {
'class': 'fl-frequency-rings',
'[class.fl-frequency-rings--reduced-motion]': 'reducedMotion.reduced()',
},
})
export class FlFrequencyRingsComponent {
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly rings = input(5);
readonly color = input('#3b82f6');
readonly speed = input(1);
readonly baseRadius = input(20);
// Computed
readonly ringData = computed(() => {
const count = this.rings();
const base = this.baseRadius();
const spd = this.speed();
return Array.from({ length: count }, (_, i) => {
const radius = base + i * 15;
const opacity = 1 - (i / count) * 0.7;
const strokeWidth = 2 - (i / count) * 1;
const duration = (2 + i * 0.3) / spd;
const delay = -i * 0.2;
return { index: i, radius, opacity, strokeWidth, duration, delay };
});
});
}

View File

@@ -0,0 +1 @@
export * from './fl-frequency-rings.component';

View File

@@ -0,0 +1,11 @@
:host {
display: block;
width: 100%;
height: 100%;
}
.fl-generative-pattern__svg {
width: 100%;
height: 100%;
display: block;
}

View File

@@ -0,0 +1,211 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef, signal, computed,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
@Component({
selector: 'fl-generative-pattern',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg
class="fl-generative-pattern__svg"
[attr.viewBox]="viewBox()"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern
[attr.id]="patternId"
[attr.width]="size()"
[attr.height]="size()"
patternUnits="userSpaceOnUse"
>
@switch (pattern()) {
@case ('triangles') {
@for (item of triangles(); track $index) {
<polygon
[attr.points]="item.points"
[attr.fill]="item.color"
[attr.opacity]="item.opacity"
/>
}
}
@case ('hexagons') {
@for (item of hexagons(); track $index) {
<polygon
[attr.points]="item.points"
[attr.fill]="item.color"
[attr.opacity]="item.opacity"
/>
}
}
@case ('circles') {
@for (item of circles(); track $index) {
<circle
[attr.cx]="item.cx"
[attr.cy]="item.cy"
[attr.r]="item.r"
[attr.fill]="item.color"
[attr.opacity]="item.opacity"
/>
}
}
@case ('squares') {
@for (item of squares(); track $index) {
<rect
[attr.x]="item.x"
[attr.y]="item.y"
[attr.width]="item.width"
[attr.height]="item.height"
[attr.fill]="item.color"
[attr.opacity]="item.opacity"
/>
}
}
}
</pattern>
</defs>
<rect width="100%" height="100%" [attr.fill]="'url(#' + patternId + ')'" />
</svg>
`,
styleUrl: './fl-generative-pattern.component.scss',
host: {
'class': 'fl-generative-pattern',
},
})
export class FlGenerativePatternComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly pattern = input<'triangles' | 'hexagons' | 'circles' | 'squares'>('triangles');
readonly size = input(50);
readonly colors = input<string[]>(['#3b82f6', '#8b5cf6', '#ec4899']);
readonly animated = input(true);
// Internal
private elapsed = 0;
private loopCleanup: (() => void) | null = null;
protected readonly patternId = `pattern-${Math.random().toString(36).slice(2)}`;
// Signals
readonly viewBox = signal('0 0 100 100');
readonly triangles = signal<Array<{ points: string; color: string; opacity: number }>>([]);
readonly hexagons = signal<Array<{ points: string; color: string; opacity: number }>>([]);
readonly circles = signal<Array<{ cx: number; cy: number; r: number; color: string; opacity: number }>>([]);
readonly squares = signal<Array<{ x: number; y: number; width: number; height: number; color: string; opacity: number }>>([]);
constructor() {
afterNextRender(() => {
this.generatePattern(0);
if (this.reducedMotion.reduced() || !this.animated()) {
return;
}
const id = `pattern-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
});
}
private tick(delta: number): void {
this.elapsed += delta * 0.5;
this.generatePattern(this.elapsed);
}
private generatePattern(phase: number): void {
const size = this.size();
const colors = this.colors();
switch (this.pattern()) {
case 'triangles':
this.generateTriangles(size, colors, phase);
break;
case 'hexagons':
this.generateHexagons(size, colors, phase);
break;
case 'circles':
this.generateCircles(size, colors, phase);
break;
case 'squares':
this.generateSquares(size, colors, phase);
break;
}
}
private generateTriangles(size: number, colors: string[], phase: number): void {
const items = [];
const half = size / 2;
for (let i = 0; i < 4; i++) {
const color = colors[i % colors.length];
const opacity = 0.5 + Math.sin(phase + i) * 0.3;
items.push({
points: `${i * half},0 ${(i + 1) * half},${half} ${i * half},${half}`,
color,
opacity,
});
}
this.triangles.set(items);
}
private generateHexagons(size: number, colors: string[], phase: number): void {
const items = [];
const cx = size / 2;
const cy = size / 2;
const r = size * 0.4;
const points = [];
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
points.push(`${x},${y}`);
}
const color = colors[0];
const opacity = 0.5 + Math.sin(phase) * 0.3;
items.push({ points: points.join(' '), color, opacity });
this.hexagons.set(items);
}
private generateCircles(size: number, colors: string[], phase: number): void {
const items = [];
const half = size / 2;
for (let i = 0; i < 4; i++) {
const color = colors[i % colors.length];
const opacity = 0.5 + Math.sin(phase + i * 0.5) * 0.3;
items.push({
cx: (i % 2) * half + half / 2,
cy: Math.floor(i / 2) * half + half / 2,
r: size * 0.2,
color,
opacity,
});
}
this.circles.set(items);
}
private generateSquares(size: number, colors: string[], phase: number): void {
const items = [];
const half = size / 2;
for (let i = 0; i < 4; i++) {
const color = colors[i % colors.length];
const opacity = 0.5 + Math.sin(phase + i * 0.5) * 0.3;
items.push({
x: (i % 2) * half,
y: Math.floor(i / 2) * half,
width: half * 0.8,
height: half * 0.8,
color,
opacity,
});
}
this.squares.set(items);
}
}

View File

@@ -0,0 +1 @@
export * from './fl-generative-pattern.component';

View File

@@ -0,0 +1,101 @@
:host {
display: block;
position: relative;
}
.fl-glitch-image__content {
--fl-glitch-intensity: 5;
--fl-glitch-speed: 2;
position: relative;
width: 100%;
height: 100%;
&--active::before,
&--active::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: inherit;
pointer-events: none;
}
&--active::before {
left: calc(var(--fl-glitch-intensity) * 1px);
animation: glitch-channel-1 calc(var(--fl-glitch-speed) * 1s) infinite linear alternate-reverse;
mix-blend-mode: screen;
filter: hue-rotate(0deg) brightness(1.2);
clip-path: inset(0);
}
&--active::after {
left: calc(var(--fl-glitch-intensity) * -1px);
animation: glitch-channel-2 calc(var(--fl-glitch-speed) * 1s) infinite linear alternate-reverse;
mix-blend-mode: screen;
filter: hue-rotate(180deg) brightness(1.2);
clip-path: inset(0);
}
&--reduced-motion::before,
&--reduced-motion::after {
animation: none !important;
display: none;
}
}
@keyframes glitch-channel-1 {
0% {
transform: translate(0);
clip-path: inset(20% 0 60% 0);
}
20% {
transform: translate(calc(var(--fl-glitch-intensity) * -1px), calc(var(--fl-glitch-intensity) * 1px));
clip-path: inset(40% 0 40% 0);
}
40% {
transform: translate(calc(var(--fl-glitch-intensity) * 1px), calc(var(--fl-glitch-intensity) * -1px));
clip-path: inset(10% 0 70% 0);
}
60% {
transform: translate(calc(var(--fl-glitch-intensity) * 1px), calc(var(--fl-glitch-intensity) * 1px));
clip-path: inset(60% 0 20% 0);
}
80% {
transform: translate(calc(var(--fl-glitch-intensity) * -1px), calc(var(--fl-glitch-intensity) * -1px));
clip-path: inset(30% 0 50% 0);
}
100% {
transform: translate(0);
clip-path: inset(50% 0 30% 0);
}
}
@keyframes glitch-channel-2 {
0% {
transform: translate(0);
clip-path: inset(60% 0 20% 0);
}
20% {
transform: translate(calc(var(--fl-glitch-intensity) * 1px), calc(var(--fl-glitch-intensity) * -1px));
clip-path: inset(10% 0 70% 0);
}
40% {
transform: translate(calc(var(--fl-glitch-intensity) * -1px), calc(var(--fl-glitch-intensity) * 1px));
clip-path: inset(50% 0 30% 0);
}
60% {
transform: translate(calc(var(--fl-glitch-intensity) * -1px), calc(var(--fl-glitch-intensity) * -1px));
clip-path: inset(20% 0 60% 0);
}
80% {
transform: translate(calc(var(--fl-glitch-intensity) * 1px), calc(var(--fl-glitch-intensity) * 1px));
clip-path: inset(40% 0 40% 0);
}
100% {
transform: translate(0);
clip-path: inset(30% 0 50% 0);
}
}

View File

@@ -0,0 +1,33 @@
import {
Component, ChangeDetectionStrategy, input, inject,
DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-glitch-image',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="fl-glitch-image__content"
[class.fl-glitch-image__content--active]="active()"
[class.fl-glitch-image__content--reduced-motion]="reducedMotion.reduced()"
[style.--fl-glitch-intensity]="intensity()"
[style.--fl-glitch-speed.s]="speed()"
>
<ng-content />
</div>
`,
styleUrl: './fl-glitch-image.component.scss',
host: { 'class': 'fl-glitch-image' },
})
export class FlGlitchImageComponent {
protected reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly intensity = input(5);
readonly speed = input(2);
readonly active = input(true);
}

View File

@@ -0,0 +1 @@
export * from './fl-glitch-image.component';

View File

@@ -0,0 +1,75 @@
.fl-glitch-text {
--fl-glitch-intensity: 5px;
--fl-glitch-speed: 1s;
display: inline-block;
position: relative;
&__content {
position: relative;
display: inline-block;
}
&--active &__content {
&::before,
&::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.8;
}
&::before {
color: #ff00ff;
animation: fl-glitch-before var(--fl-glitch-speed) infinite;
clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%);
z-index: -1;
}
&::after {
color: #00ffff;
animation: fl-glitch-after var(--fl-glitch-speed) infinite reverse;
clip-path: polygon(0 55%, 100% 55%, 100% 100%, 0 100%);
z-index: -2;
}
}
}
@keyframes fl-glitch-before {
0%, 100% {
transform: translate(0, 0);
}
20% {
transform: translate(calc(var(--fl-glitch-intensity) * -1), var(--fl-glitch-intensity));
}
40% {
transform: translate(var(--fl-glitch-intensity), calc(var(--fl-glitch-intensity) * -1));
}
60% {
transform: translate(calc(var(--fl-glitch-intensity) * -1), var(--fl-glitch-intensity));
}
80% {
transform: translate(var(--fl-glitch-intensity), calc(var(--fl-glitch-intensity) * -1));
}
}
@keyframes fl-glitch-after {
0%, 100% {
transform: translate(0, 0);
}
20% {
transform: translate(var(--fl-glitch-intensity), calc(var(--fl-glitch-intensity) * -1));
}
40% {
transform: translate(calc(var(--fl-glitch-intensity) * -1), var(--fl-glitch-intensity));
}
60% {
transform: translate(var(--fl-glitch-intensity), calc(var(--fl-glitch-intensity) * -1));
}
80% {
transform: translate(calc(var(--fl-glitch-intensity) * -1), var(--fl-glitch-intensity));
}
}

View File

@@ -0,0 +1,53 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef, effect,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-glitch-text',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<span class="fl-glitch-text__content" [attr.data-text]="text()">{{ text() }}</span>
`,
styleUrl: './fl-glitch-text.component.scss',
host: { 'class': 'fl-glitch-text' },
})
export class FlGlitchTextComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly text = input.required<string>();
readonly intensity = input(5);
readonly speed = input(1);
readonly active = input(true);
constructor() {
afterNextRender(() => {
this.updateStyles();
});
effect(() => {
this.updateStyles();
});
}
private updateStyles(): void {
const host = this.el.nativeElement as HTMLElement;
const intensity = this.intensity();
const speed = this.speed();
const active = this.active();
if (this.reducedMotion.reduced() || !active) {
host.classList.remove('fl-glitch-text--active');
return;
}
host.classList.add('fl-glitch-text--active');
host.style.setProperty('--fl-glitch-intensity', `${intensity}px`);
host.style.setProperty('--fl-glitch-speed', `${1 / speed}s`);
}
}

View File

@@ -0,0 +1 @@
export * from './fl-glitch-text.component';

View File

@@ -0,0 +1,35 @@
:host {
display: inline-block;
}
.fl-glow-badge__inner {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
background: currentColor;
color: white;
font-size: 0.875rem;
font-weight: 500;
will-change: box-shadow;
&--pulse {
animation: fl-glow-badge-pulse 2s ease-in-out infinite;
}
}
@keyframes fl-glow-badge-pulse {
0%, 100% {
filter: brightness(1);
}
50% {
filter: brightness(1.3);
}
}
:host.fl-glow-badge--reduced-motion {
.fl-glow-badge__inner--pulse {
animation: none;
}
}

View File

@@ -0,0 +1,42 @@
import {
Component, ChangeDetectionStrategy, input, inject, computed,
DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-glow-badge',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="fl-glow-badge__inner"
[style.color]="color()"
[style.box-shadow]="boxShadow()"
[class.fl-glow-badge__inner--pulse]="pulse()"
>
<ng-content />
</div>
`,
styleUrl: './fl-glow-badge.component.scss',
host: {
'class': 'fl-glow-badge',
'[class.fl-glow-badge--reduced-motion]': 'reducedMotion.reduced()',
},
})
export class FlGlowBadgeComponent {
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly color = input('#3b82f6');
readonly glowSize = input(10);
readonly pulse = input(false);
// Computed
readonly boxShadow = computed(() => {
const size = this.glowSize();
const c = this.color();
return `0 0 ${size}px ${c}, 0 0 ${size * 2}px ${c}`;
});
}

View File

@@ -0,0 +1 @@
export * from './fl-glow-badge.component';

View File

@@ -0,0 +1,31 @@
.fl-glow-divider {
--fl-glow-color: #00ffff;
--fl-glow-size: 8px;
display: block;
width: 100%;
&__line {
width: 100%;
border-radius: 999px;
&--pulse {
animation: fl-glow-divider-pulse 2s ease-in-out infinite;
}
}
}
@keyframes fl-glow-divider-pulse {
0%, 100% {
box-shadow:
0 0 var(--fl-glow-size) var(--fl-glow-color),
0 0 calc(var(--fl-glow-size) * 2) var(--fl-glow-color),
0 0 calc(var(--fl-glow-size) * 3) var(--fl-glow-color);
}
50% {
box-shadow:
0 0 calc(var(--fl-glow-size) * 1.5) var(--fl-glow-color),
0 0 calc(var(--fl-glow-size) * 3) var(--fl-glow-color),
0 0 calc(var(--fl-glow-size) * 4) var(--fl-glow-color);
}
}

View File

@@ -0,0 +1,57 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-glow-divider',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div class="fl-glow-divider__line"></div>`,
styleUrl: './fl-glow-divider.component.scss',
host: { 'class': 'fl-glow-divider' },
})
export class FlGlowDividerComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly color = input('#00ffff');
readonly width = input(2);
readonly glowSize = input(8);
readonly pulse = input(true);
constructor() {
afterNextRender(() => {
this.updateStyles();
});
}
private updateStyles(): void {
const host = this.el.nativeElement as HTMLElement;
const line = host.querySelector('.fl-glow-divider__line') as HTMLElement;
if (!line) return;
const color = this.color();
const width = this.width();
const glowSize = this.glowSize();
const pulse = this.pulse();
line.style.backgroundColor = color;
line.style.height = `${width}px`;
line.style.boxShadow = `
0 0 ${glowSize}px ${color},
0 0 ${glowSize * 2}px ${color},
0 0 ${glowSize * 3}px ${color}
`;
if (pulse && !this.reducedMotion.reduced()) {
line.classList.add('fl-glow-divider__line--pulse');
line.style.setProperty('--fl-glow-color', color);
line.style.setProperty('--fl-glow-size', `${glowSize}px`);
}
}
}

View File

@@ -0,0 +1 @@
export * from './fl-glow-divider.component';

View File

@@ -0,0 +1,22 @@
.fl-gradient-divider {
display: block;
width: 100%;
&__line {
width: 100%;
border-radius: 999px;
&--animated {
animation: fl-gradient-divider-slide 3s ease-in-out infinite;
}
}
}
@keyframes fl-gradient-divider-slide {
0%, 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}

View File

@@ -0,0 +1,50 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-gradient-divider',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div class="fl-gradient-divider__line"></div>`,
styleUrl: './fl-gradient-divider.component.scss',
host: { 'class': 'fl-gradient-divider' },
})
export class FlGradientDividerComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly colors = input<string[]>(['#667eea', '#764ba2', '#f093fb']);
readonly height = input(2);
readonly animated = input(true);
constructor() {
afterNextRender(() => {
this.updateStyles();
});
}
private updateStyles(): void {
const host = this.el.nativeElement as HTMLElement;
const line = host.querySelector('.fl-gradient-divider__line') as HTMLElement;
if (!line) return;
const colors = this.colors();
const height = this.height();
const animated = this.animated();
const gradient = `linear-gradient(90deg, ${colors.join(', ')})`;
line.style.background = gradient;
line.style.height = `${height}px`;
if (animated && !this.reducedMotion.reduced()) {
line.style.backgroundSize = '200% 100%';
line.classList.add('fl-gradient-divider__line--animated');
}
}
}

View File

@@ -0,0 +1 @@
export * from './fl-gradient-divider.component';

View File

@@ -0,0 +1,19 @@
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
.fl-gradient-mesh__blob {
position: absolute;
width: 40%;
height: 40%;
border-radius: 50%;
opacity: 0.6;
transform: translate(-50%, -50%);
will-change: left, top;
pointer-events: none;
}

View File

@@ -0,0 +1,121 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
interface MeshPoint {
x: number;
y: number;
vx: number;
vy: number;
color: string;
}
@Component({
selector: 'fl-gradient-mesh',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (point of meshPoints(); track $index) {
<div
class="fl-gradient-mesh__blob"
[style.left.%]="point.x"
[style.top.%]="point.y"
[style.background]="point.color"
[style.filter]="'blur(' + blur() + 'px)'"
></div>
}
`,
styleUrl: './fl-gradient-mesh.component.scss',
host: {
'class': 'fl-gradient-mesh',
},
})
export class FlGradientMeshComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly points = input(5);
readonly colors = input<string[]>(['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b']);
readonly speed = input(1);
readonly blur = input(80);
// Internal
private meshPointsData: MeshPoint[] = [];
private loopCleanup: (() => void) | null = null;
// Signals
readonly meshPoints = signal<Array<{ x: number; y: number; color: string }>>([]);
constructor() {
afterNextRender(() => {
this.initializeMesh();
if (this.reducedMotion.reduced()) {
this.updateDisplay();
return;
}
const id = `gradient-mesh-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
});
}
private initializeMesh(): void {
const count = this.points();
const colors = this.colors();
this.meshPointsData = [];
for (let i = 0; i < count; i++) {
this.meshPointsData.push({
x: Math.random() * 100,
y: Math.random() * 100,
vx: (Math.random() - 0.5) * 10,
vy: (Math.random() - 0.5) * 10,
color: colors[i % colors.length],
});
}
this.updateDisplay();
}
private tick(delta: number): void {
const speed = this.speed() * delta * 10;
for (const point of this.meshPointsData) {
point.x += point.vx * speed;
point.y += point.vy * speed;
// Bounce off edges
if (point.x < 0 || point.x > 100) {
point.vx *= -1;
point.x = Math.max(0, Math.min(100, point.x));
}
if (point.y < 0 || point.y > 100) {
point.vy *= -1;
point.y = Math.max(0, Math.min(100, point.y));
}
}
this.updateDisplay();
}
private updateDisplay(): void {
this.meshPoints.set(
this.meshPointsData.map(p => ({
x: p.x,
y: p.y,
color: p.color,
}))
);
}
}

View File

@@ -0,0 +1 @@
export * from './fl-gradient-mesh.component';

View File

@@ -0,0 +1,8 @@
.fl-gradient-text {
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
display: inline-block;
transition: background-position 0.3s ease;
}

View File

@@ -0,0 +1,60 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
@Component({
selector: 'fl-gradient-text',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<ng-content />`,
styleUrl: './fl-gradient-text.component.scss',
host: { 'class': 'fl-gradient-text' },
})
export class FlGradientTextComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly colors = input<string[]>(['#ff6b6b', '#4ecdc4', '#45b7d1', '#f7b731']);
readonly speed = input(1);
readonly angle = input(45);
private loopCleanup: (() => void) | null = null;
private position = 0;
constructor() {
afterNextRender(() => {
this.updateGradient();
if (!this.reducedMotion.reduced()) {
const id = `gradient-text-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
}
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
});
}
private tick(delta: number): void {
this.position += this.speed() * delta * 50;
this.updateGradient();
}
private updateGradient(): void {
const host = this.el.nativeElement as HTMLElement;
const colorStops = this.colors().join(', ');
const angle = this.angle();
const pos = this.position % 200;
host.style.backgroundImage = `linear-gradient(${angle}deg, ${colorStops})`;
host.style.backgroundSize = '200% 200%';
host.style.backgroundPosition = `${pos}% ${pos}%`;
}
}

View File

@@ -0,0 +1 @@
export * from './fl-gradient-text.component';

View File

@@ -0,0 +1,44 @@
:host {
display: block;
width: 100%;
height: 100%;
perspective: 1000px;
}
.fl-grid-matrix__grid {
display: grid;
width: 100%;
height: 100%;
gap: 8px;
padding: 20px;
transform: rotateX(45deg) rotateZ(45deg);
transform-style: preserve-3d;
}
.fl-grid-matrix__dot {
border-radius: 50%;
opacity: 0.6;
transition: opacity 0.3s ease;
&.fl-grid-matrix__dot--animated {
animation: fl-grid-matrix-pulse 3s ease-in-out infinite;
}
}
@keyframes fl-grid-matrix-pulse {
0%, 100% {
transform: scale(1);
opacity: 0.4;
}
50% {
transform: scale(1.5);
opacity: 0.8;
}
}
:host.fl-grid-matrix--reduced-motion {
.fl-grid-matrix__dot {
animation: none !important;
opacity: 0.6;
}
}

View File

@@ -0,0 +1,63 @@
import {
Component, ChangeDetectionStrategy, input, inject, computed,
DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-grid-matrix',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="fl-grid-matrix__grid"
[style.grid-template-columns]="'repeat(' + cols() + ', 1fr)'"
[style.grid-template-rows]="'repeat(' + rows() + ', 1fr)'"
>
@for (dot of dots(); track dot.index) {
<div
class="fl-grid-matrix__dot"
[class.fl-grid-matrix__dot--animated]="animated()"
[style.width.px]="dotSize()"
[style.height.px]="dotSize()"
[style.background-color]="color()"
[style.animation-delay.s]="dot.delay"
></div>
}
</div>
`,
styleUrl: './fl-grid-matrix.component.scss',
host: {
'class': 'fl-grid-matrix',
'[class.fl-grid-matrix--reduced-motion]': 'reducedMotion.reduced()',
},
})
export class FlGridMatrixComponent {
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly rows = input(10);
readonly cols = input(10);
readonly dotSize = input(4);
readonly color = input('#3b82f6');
readonly animated = input(true);
// Computed
readonly dots = computed(() => {
const r = this.rows();
const c = this.cols();
const total = r * c;
return Array.from({ length: total }, (_, i) => {
const row = Math.floor(i / c);
const col = i % c;
const distanceFromCenter = Math.sqrt(
Math.pow(row - r / 2, 2) + Math.pow(col - c / 2, 2)
);
const delay = distanceFromCenter * 0.05;
return { index: i, delay };
});
});
}

View File

@@ -0,0 +1 @@
export * from './fl-grid-matrix.component';

View File

@@ -0,0 +1,34 @@
:host {
display: block;
width: 100%;
height: 60px;
overflow: hidden;
}
.fl-heartbeat-line__svg {
width: 100%;
height: 100%;
path {
animation: fl-heartbeat-line-scroll 2s linear infinite;
stroke-dasharray: 400;
stroke-dashoffset: 400;
}
}
@keyframes fl-heartbeat-line-scroll {
0% {
stroke-dashoffset: 400;
}
100% {
stroke-dashoffset: 0;
}
}
:host.fl-heartbeat-line--reduced-motion {
.fl-heartbeat-line__svg path {
animation: none;
stroke-dasharray: none;
stroke-dashoffset: 0;
}
}

View File

@@ -0,0 +1,65 @@
import {
Component, ChangeDetectionStrategy, input, inject, computed,
DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
@Component({
selector: 'fl-heartbeat-line',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg class="fl-heartbeat-line__svg" viewBox="0 0 200 60" preserveAspectRatio="none">
<path
[attr.d]="heartbeatPath"
fill="none"
[attr.stroke]="color()"
[attr.stroke-width]="lineWidth()"
stroke-linecap="round"
stroke-linejoin="round"
[style.animation-duration.s]="animationDuration()"
/>
</svg>
`,
styleUrl: './fl-heartbeat-line.component.scss',
host: {
'class': 'fl-heartbeat-line',
'[class.fl-heartbeat-line--reduced-motion]': 'reducedMotion.reduced()',
},
})
export class FlHeartbeatLineComponent {
private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly color = input('#ef4444');
readonly rate = input(1);
readonly lineWidth = input(2);
// Computed
readonly animationDuration = computed(() => 2 / this.rate());
// Heartbeat path data (EKG-style waveform)
readonly heartbeatPath = `
M 0,30
L 40,30
L 42,30
L 44,15
L 46,40
L 48,20
L 50,30
L 90,30
L 92,30
L 94,15
L 96,40
L 98,20
L 100,30
L 140,30
L 142,30
L 144,15
L 146,40
L 148,20
L 150,30
L 200,30
`;
}

View File

@@ -0,0 +1 @@
export * from './fl-heartbeat-line.component';

View File

@@ -0,0 +1,28 @@
:host {
display: block;
position: relative;
overflow: hidden;
border-radius: inherit;
@media (prefers-reduced-motion: reduce) {
.fl-holographic-card__sheen {
display: none;
}
}
}
.fl-holographic-card__content {
position: relative;
z-index: 1;
}
.fl-holographic-card__sheen {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
mix-blend-mode: color-dodge;
opacity: 0;
transition: opacity 300ms ease;
border-radius: inherit;
}

Some files were not shown because too many files have changed in this diff Show More