diff --git a/apps/docs/src/app/page.tsx b/apps/docs/src/app/page.tsx
index cf4330b..18ede22 100644
--- a/apps/docs/src/app/page.tsx
+++ b/apps/docs/src/app/page.tsx
@@ -13,7 +13,9 @@ import {
Meta,
Schema,
MatrixFx,
- Background
+ Background,
+ Pulse,
+ ShineFx
} from "@once-ui-system/core";
import { baseURL, meta, schema, changelog, roadmap, layout } from "@/resources";
import { formatDate } from "./utils/formatDate";
@@ -89,7 +91,7 @@ export default function Home() {
top="0"
left="0"
flicker
- colors={["brand-solid-strong"]}
+ colors={["brand-solid-strong"]}
bulge={{
type: "wave",
duration: 3,
@@ -101,20 +103,27 @@ export default function Home() {
- NEW
-
- Once UI 1.5 — Curiosity in code
-
+
+
+
+ Once UI 1.5 Curiosity in code
+ New
+
+
Once UI Docs
diff --git a/apps/docs/src/content/once-ui/components/avatar.mdx b/apps/docs/src/content/once-ui/components/avatar.mdx
index 90869b5..0572951 100644
--- a/apps/docs/src/content/once-ui/components/avatar.mdx
+++ b/apps/docs/src/content/once-ui/components/avatar.mdx
@@ -6,6 +6,8 @@ docs: "once-ui/components/avatar.mdx"
github: "components/Avatar.tsx"
navLabel: "Avatar"
navIcon: "components"
+navTag: "Update"
+navTagVariant: "indigo"
---
A versatile component for displaying user profile images, initials, or fallback icons in a circular container. Commonly used in user interfaces to represent people.
@@ -34,6 +36,50 @@ A versatile component for displaying user profile images, initials, or fallback
]}
/>
+## Empty
+
+Use the `empty` prop to render an empty avatar.
+
+
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Empty"
+ }
+ ]}
+/>
+
+## Icon
+
+Use the `icon` prop to render an icon in the avatar.
+
+
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Icon"
+ }
+ ]}
+/>
+
## Sizes
Use the T-shirt size model or set sizes with numbers (REM).
@@ -143,6 +189,7 @@ Use the T-shirt size model or set sizes with numbers (REM).
["size", ["xs", "s", "m", "l", "xl", "number"], "m"],
["value", "string"],
["src", "string"],
+ ["icon", "string"],
["loading", "boolean", "false"],
["empty", "boolean", "false"],
["statusIndicator", "object"],
diff --git a/apps/docs/src/content/once-ui/components/media.mdx b/apps/docs/src/content/once-ui/components/media.mdx
index 6792f5b..12198d0 100644
--- a/apps/docs/src/content/once-ui/components/media.mdx
+++ b/apps/docs/src/content/once-ui/components/media.mdx
@@ -16,6 +16,7 @@ navIcon: "components"
preview={
`,
@@ -49,6 +51,7 @@ Show a skeleton block while content is loading. Only works when `aspectRatio` is
loading
radius="l"
aspectRatio="16/9"
+ sizes="(max-width: 768px) 100vw, 768px"
src="/images/docs/once-ui/vibe-coding-light.jpg"
/>
}
@@ -79,6 +82,7 @@ The `caption` prop renders a `ReactNode` below the image, making it easy to prov
diff --git a/apps/docs/src/content/once-ui/components/segmentedControl.mdx b/apps/docs/src/content/once-ui/components/segmentedControl.mdx
index 91bdb4d..483c6b1 100644
--- a/apps/docs/src/content/once-ui/components/segmentedControl.mdx
+++ b/apps/docs/src/content/once-ui/components/segmentedControl.mdx
@@ -6,6 +6,8 @@ docs: "once-ui/components/segmentedControl.mdx"
github: "components/SegmentedControl.tsx"
navLabel: "Segmented Control"
navIcon: "components"
+navTag: "Update"
+navTagVariant: "indigo"
---
Use this component for toggle-style selection between discrete options. It supports keyboard interaction, styling, and controlled or uncontrolled usage.
@@ -41,9 +43,48 @@ Use this component for toggle-style selection between discrete options. It suppo
]}
/>
-## Default selected
+## External state
-Use `defaultSelected` to define an initial active button in uncontrolled mode.
+Use `selected` to manage the selected state from the parent.
+
+
+
+
+ }
+ codes={[
+ {
+ code:
+` console.log(value)}
+/>`,
+ language: "tsx",
+ label: "External state"
+ }
+ ]}
+/>
+
+## Internal state
+
+Use `defaultSelected` to define an initial active button in uncontrolled mode. Don't use it along with the `selected` prop.
console.log(value)}
/>`,
language: "tsx",
- label: "Default selected"
+ label: "Internal state"
+ }
+ ]}
+/>
+
+## Compact
+
+Use `compact` to create a compact variant.
+
+
+
+
+ }
+ codes={[
+ {
+ code:
+` console.log(value)}
+/>`,
+ language: "tsx",
+ label: "Compact"
}
]}
/>
diff --git a/apps/docs/src/content/once-ui/data/linearGauge.mdx b/apps/docs/src/content/once-ui/data/linearGauge.mdx
new file mode 100644
index 0000000..0b57734
--- /dev/null
+++ b/apps/docs/src/content/once-ui/data/linearGauge.mdx
@@ -0,0 +1,281 @@
+---
+title: "LinearGauge"
+summary: "A horizontal, tick-based gauge for visualizing progress, metrics, and health with flexible label configurations."
+updatedAt: "2025-11-16"
+docs: "once-ui/data/linearGauge.mdx"
+github: "modules/data/LinearGauge.tsx"
+navLabel: "LinearGauge"
+navIcon: "linearGauge"
+navTag: "New"
+navTagVariant: "cyan"
+---
+
+The `LinearGauge` component renders a horizontal tick-based gauge ideal for progress indicators, metrics dashboards, and system health displays. It supports flexible label configurations including none, percentage, or custom values.
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Usage"
+ }
+ ]}
+/>
+
+### Labels
+
+Show percentage labels at key intervals (0%, 25%, 50%, 75%, 100%).
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Percentage"
+ }
+ ]}
+/>
+
+Provide an array of custom labels distributed evenly across the gauge.
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Custom"
+ }
+ ]}
+/>
+
+### Reverse order
+
+Flip the order of labels and gauge by using `direction="column-reverse"`.
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Reverse"
+ }
+ ]}
+/>
+
+### Health status
+
+Use the `hue` prop to represent different states with color gradients.
+
+
+
+
+
+
+
+ }
+ codes={[
+ {
+ code:
+`
+
+
+
+
+`,
+ language: "tsx",
+ label: "States"
+ }
+ ]}
+/>
+
+## Ticks
+
+Customize tick count, width, and length for different visual densities.
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Density"
+ }
+ ]}
+/>
+
+## Props
+
+The `LinearGauge` component accepts all layout props from `Flex` (except `direction`) plus the following gauge-specific props:
+
+
+
+Use `LinearGauge` for horizontal metrics displays, progress tracking, or any scenario where a linear representation is more appropriate than a radial one.
diff --git a/apps/docs/src/content/once-ui/data/meta.json b/apps/docs/src/content/once-ui/data/meta.json
index 0637bbf..3bf69aa 100644
--- a/apps/docs/src/content/once-ui/data/meta.json
+++ b/apps/docs/src/content/once-ui/data/meta.json
@@ -7,6 +7,7 @@
"barChart": 3,
"groupedBarChart": 4,
"lineBarChart": 5,
- "pieChart": 6
+ "pieChart": 6,
+ "radialGauge": 7
}
}
\ No newline at end of file
diff --git a/apps/docs/src/content/once-ui/data/radialGauge.mdx b/apps/docs/src/content/once-ui/data/radialGauge.mdx
new file mode 100644
index 0000000..3fa015f
--- /dev/null
+++ b/apps/docs/src/content/once-ui/data/radialGauge.mdx
@@ -0,0 +1,359 @@
+---
+title: "RadialGauge"
+summary: "A radial, tick-based gauge for visualizing health metrics and real-time values using circular or arc-based layouts."
+updatedAt: "2025-11-15"
+docs: "once-ui/data/radialGauge.mdx"
+github: "modules/data/RadialGauge.tsx"
+navLabel: "RadialGauge"
+navIcon: "radialGauge"
+navTag: "New"
+navTagVariant: "cyan"
+---
+
+The `RadialGauge` component renders a tick-based circular or arc-shaped gauge that is ideal for health metrics, KPIs, and other real-time values. It supports full circles, semicircles, and custom arcs with configurable angles, direction, density, and visual health states.
+
+
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Usage"
+ }
+ ]}
+/>
+
+## Hue
+
+Use the `hue` prop to quickly change the color ramp of active ticks to represent different states.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ codes={[
+ {
+ code:
+`
+
+
+`,
+ language: "tsx",
+ label: "Hue"
+ }
+ ]}
+/>
+
+## Angles
+
+You can create semicircular or arc gauges by combining `angle` and `edgePad`. The angle system is **intuitive**:
+
+- `0`° = left
+- `90`° = top
+- `180`° = right
+- `270`° = bottom
+
+
+
+
+ 68%
+
+
+
+ }
+ codes={[
+ {
+ code:
+`
+
+ 68%
+
+`,
+ language: "tsx",
+ label: "Angles"
+ }
+ ]}
+/>
+
+## Line tick
+
+Control how many ticks are rendered and how long they are using `lineCount`, `lineWidth`, and `lineLength`.
+
+
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Density"
+ }
+ ]}
+/>
+
+## Animation
+
+For interactive demos or dashboards, you can animate the `value` over time on the client. The example below simulates accelerating and braking.
+
+
+
+
+ }
+ codes={[
+ {
+ code:
+`"use client";
+
+import React, { useEffect, useState } from "react";
+import { RadialGauge, Column, Text } from "@once-ui-system/core";
+
+export function RadialGaugeSpeedDemo() {
+ const [value, setValue] = useState(20);
+ const [phase, setPhase] = useState<"idle" | "accelerate" | "brake">("idle");
+
+ useEffect(() => {
+ let timeout: ReturnType | undefined;
+ let interval: ReturnType | undefined;
+
+ const startAccelerate = () => {
+ setPhase("accelerate");
+ interval = setInterval(() => {
+ setValue(prev => {
+ const next = Math.min(prev + 6, 96);
+ if (next >= 96) {
+ clearInterval(interval);
+ timeout = setTimeout(startBrake, 600);
+ }
+ return next;
+ });
+ }, 90);
+ };
+
+ const startBrake = () => {
+ setPhase("brake");
+ interval = setInterval(() => {
+ setValue(prev => {
+ const next = Math.max(prev - 8, 18);
+ if (next <= 18) {
+ clearInterval(interval);
+ timeout = setTimeout(startAccelerate, 800);
+ }
+ return next;
+ });
+ }, 90);
+ };
+
+ timeout = setTimeout(startAccelerate, 400);
+
+ return () => {
+ if (interval) clearInterval(interval);
+ if (timeout) clearTimeout(timeout);
+ };
+ }, []);
+
+ const label = phase === "accelerate" ? "Accelerating" : phase === "brake" ? "Braking" : "Idle";
+
+ return (
+
+
+
+ {label}
+
+
+ );
+}`,
+ language: "tsx",
+ label: "Animated speed meter"
+ }
+ ]}
+/>
+
+## Props
+
+The `RadialGauge` component accepts all layout props from `Column` (except `direction`) plus the following gauge-specific props:
+
+
+
+Use `RadialGauge` when you need a compact, highly legible representation of a single metric that can be scanned quickly, such as system health, SLO burn rate, or real-time utilization.
diff --git a/apps/docs/src/content/once-ui/effects/celebrationFx.mdx b/apps/docs/src/content/once-ui/effects/celebrationFx.mdx
new file mode 100644
index 0000000..ba77cd3
--- /dev/null
+++ b/apps/docs/src/content/once-ui/effects/celebrationFx.mdx
@@ -0,0 +1,390 @@
+---
+title: "CelebrationFx"
+summary: "The CelebrationFx component creates animated celebration effects like confetti and fireworks using native Canvas 2D API."
+updatedAt: "2025-11-16"
+docs: "once-ui/effects/celebrationFx.mdx"
+github: "components/CelebrationFx.tsx"
+navLabel: "CelebrationFx"
+navIcon: "sparkle"
+navTag: "New"
+navTagVariant: "cyan"
+---
+
+The `CelebrationFx` component creates animated celebration effects using the native Canvas 2D API. Choose between confetti or fireworks animations with multiple trigger modes, customizable colors, and intensity controls. Perfect for success states, achievements, milestones, or interactive celebrations.
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Usage"
+ }
+ ]}
+/>
+
+## Celebration types
+
+Choose between `confetti` (falling particles) or `fireworks` (explosive bursts).
+
+
+
+
+
+ }
+ codes={[
+ {
+ code:
+`
+`,
+ language: "tsx",
+ label: "Types"
+ }
+ ]}
+/>
+
+## Trigger
+
+Control when the celebration starts with `mount` (automatic), `hover` (on mouse enter), `click` (fires from click position), or `manual` (controlled via `active` prop).
+
+
+
+ Hover me!
+
+
+ }
+ codes={[
+ {
+ code:
+`
+
+ Hover me!
+
+`,
+ language: "tsx",
+ label: "Hover"
+ }
+ ]}
+/>
+
+Use `trigger="click"` to fire celebrations from exactly where users click. Perfect for interactive celebrations!
+
+
+
+
+ Click for fireworks!
+
+
+
+
+ Click for confetti!
+
+
+
+ }
+ codes={[
+ {
+ code:
+`
+
+ Click for fireworks!
+
+
+
+
+ Click for confetti!
+
+`,
+ language: "tsx",
+ label: "Click"
+ }
+ ]}
+/>
+
+## Color
+
+Customize celebration colors using design tokens or any valid CSS color.
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Colors"
+ }
+ ]}
+/>
+
+### Intensity & speed
+
+Control particle density with `intensity` and animation speed with `speed`.
+
+
+
+
+
+ }
+ codes={[
+ {
+ code:
+`
+`,
+ language: "tsx",
+ label: "Intensity"
+ }
+ ]}
+/>
+
+Control the celebration programmatically using `trigger="manual"` and the `active` prop.
+
+
+ }
+ codes={[
+ {
+ code:
+`"use client";
+
+import React, { useState } from "react";
+import { CelebrationFx, Button, Column } from "@once-ui-system/core";
+
+export function demo() {
+ const [active, setActive] = useState(false);
+
+ return (
+
+
+
+
+ );
+}`,
+ language: "tsx",
+ label: "Manual"
+ }
+ ]}
+/>
+
+### Custom content
+
+Use `CelebrationFx` as a wrapper to overlay celebrations behind any content.
+
+
+
+
+ Achievement Unlocked!
+
+ You've completed all challenges
+
+
+
+ }
+ codes={[
+ {
+ code:
+`
+
+
+ Achievement Unlocked!
+
+ You've completed all challenges
+
+
+`,
+ language: "tsx",
+ label: "Overlay"
+ }
+ ]}
+/>
+
+## Props
+
+The `CelebrationFx` component extends all layout props from `Flex` plus the following celebration-specific props:
+
+
+
+Use `CelebrationFx` to add delightful, performant celebration animations to success states, milestones, achievements, or any moment worth celebrating in your application.
diff --git a/apps/docs/src/content/once-ui/effects/matrixFx.mdx b/apps/docs/src/content/once-ui/effects/matrixFx.mdx
index 15cdefb..cf11f36 100644
--- a/apps/docs/src/content/once-ui/effects/matrixFx.mdx
+++ b/apps/docs/src/content/once-ui/effects/matrixFx.mdx
@@ -95,9 +95,9 @@ Use multiple colors for variety. Supports CSS variables and any valid color form
``,
language: "tsx",
@@ -271,10 +271,10 @@ Add dynamic wave effects to create fluid, organic motion. Choose between `ripple
height={24}
colors={["accent-solid-strong"]}
bulge={{
- type: "ripple", // Circular wave from center
- duration: 4, // Wave duration in seconds
- intensity: 15, // Displacement strength
- repeat: true // Continuous cycles
+ type: "ripple", // Circular wave from center
+ duration: 4, // Wave duration in seconds
+ intensity: 15, // Displacement strength
+ repeat: true // Continuous cycles
}}
/>`,
language: "tsx",
@@ -308,10 +308,10 @@ The `wave` type creates an organic, flowing S-curve that travels diagonally from
height={24}
colors={["success-solid-strong"]}
bulge={{
- type: "wave", // S-curve diagonal flow
- duration: 3,
- intensity: 20,
- repeat: true
+ type: "wave", // S-curve diagonal flow
+ duration: 3,
+ intensity: 20,
+ repeat: true
}}
/>`,
language: "tsx",
@@ -345,9 +345,9 @@ Use `repeat: false` for a single wave that plays once. Add `delay` for a pause b
height={24}
colors={["danger-solid-strong"]}
bulge={{
- duration: 3,
- intensity: 12,
- repeat: false // Single wave only
+ duration: 3,
+ intensity: 12,
+ repeat: false // Single wave only
}}
/>`,
language: "tsx",
@@ -356,14 +356,14 @@ Use `repeat: false` for a single wave that plays once. Add `delay` for a pause b
]}
/>
-## Hover
+## Trigger
Use `trigger="hover"` for native hover support. Dots progressively appear on hover and smoothly reverse when you move away.
@@ -528,6 +528,72 @@ Use `trigger="hover"` for native hover support. Dots progressively appear on hov
]}
/>
+Use `trigger="click"` to toggle the effect on click.
+
+
+
+ Click to toggle
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Click Trigger"
+ }
+ ]}
+/>
+
+Use `trigger="manual"` with the `active` prop to control the effect from parent state.
+
+
+ }
+ codes={[
+ {
+ code:
+`"use client";
+
+import React from "react";
+import { MatrixFx, Row, ToggleButton } from "@once-ui-system/core";
+
+export function Demo() {
+ const [on, setOn] = React.useState(false);
+ return (
+
+ setOn((v) => !v)}>
+ {on ? "Stop" : "Start"}
+
+
+
+ );
+}
+`,
+ language: "tsx",
+ label: "Manual Trigger"
+ }
+ ]}
+/>
+
## API reference
-
- Fast shine (2s)
+
+ Fast shine - default (1s)
- Default shine (5s)
+ Medium shine (5s)
Slow shine (10s)
@@ -55,16 +55,16 @@ Adjust the animation speed with the `speed` prop. Higher values create slower an
codes={[
{
code:
-`
- Fast shine (2s)
+`
+ Fast shine - default (1s)
-
- Default shine (5s)
+
+ Medium shine (3s)
-
- Slow shine (10s)
+
+ Slow shine (5s)
`,
language: "tsx",
label: "Speed"
@@ -72,6 +72,32 @@ Adjust the animation speed with the `speed` prop. Higher values create slower an
]}
/>
+## Inverse
+
+Use the `inverse` prop to invert the shine effect.
+
+
+
+ Inverse shine
+
+
+ }
+ codes={[
+ {
+ code:
+`
+ Inverse shine
+`,
+ language: "tsx",
+ label: "Inverse"
+ }
+ ]}
+/>
+
## Base opacity
Control the base text visibility with the `baseOpacity` prop (0-1). Higher values improve readability and accessibility, while lower values create a more subtle effect.
diff --git a/apps/docs/src/content/once-ui/effects/weatherFx.mdx b/apps/docs/src/content/once-ui/effects/weatherFx.mdx
index 9070958..d386d56 100644
--- a/apps/docs/src/content/once-ui/effects/weatherFx.mdx
+++ b/apps/docs/src/content/once-ui/effects/weatherFx.mdx
@@ -1,6 +1,6 @@
---
title: "WeatherFx"
-summary: "The WeatherFx component creates animated weather effects (rain, snow, leaves) using native Canvas 2D API."
+summary: "The WeatherFx component creates animated weather effects (rain, snow, leaves, lightning) using native Canvas 2D API."
updatedAt: "2025-10-25"
docs: "once-ui/effects/WeatherFx.mdx"
github: "components/WeatherFx.tsx"
@@ -10,7 +10,7 @@ navTag: "New"
navTagVariant: "cyan"
---
-The `WeatherFx` component creates animated weather effects using the native Canvas 2D API. It supports rain, snow, and falling leaves with customizable colors, speed, and intensity. Perfect for backgrounds, hero sections, or thematic content areas.
+The `WeatherFx` component creates animated weather effects using the native Canvas 2D API. It supports rain, snow, falling leaves, and lightning with customizable colors, speed, and intensity. Perfect for backgrounds, hero sections, or thematic content areas.
Leaves
+
+
+ Lightning
+
+
}
codes={[
@@ -130,6 +141,13 @@ Switch between different weather effects: rain, snow, and falling leaves.
colors={["warning-solid-strong", "danger-solid-strong"]}
intensity={40}
speed={0.7}
+/>
+
+`,
language: "tsx",
label: "Types"
@@ -669,6 +687,131 @@ Leaves feature gradient colors, rotation animation, and tumbling motion for an a
]}
/>
+## Lightning Effect
+
+Lightning features highly realistic bolt generation with chaotic, organic branching at all angles. Each bolt creates dense networks of branches spreading in multiple directions with up to 4 levels of hierarchy. The effect uses small, erratic segments and high branching probability to create the natural, fractal-like appearance of real lightning. Bolts animate from top to bottom with progressively thinner branches. The `intensity` prop controls both the frequency and number of simultaneous strikes.
+
+
+
+
+ Thunderstorm
+
+
+ Dynamic lightning with branching bolts
+
+
+
+ }
+ codes={[
+ {
+ code:
+`
+
+
+ Thunderstorm
+
+
+ Dynamic lightning with branching bolts
+
+
+`,
+ language: "tsx",
+ label: "Lightning Effect"
+ }
+ ]}
+/>
+
+## Lightning Intensity
+
+For lightning, the `intensity` prop controls strike frequency and bolt patterns. The effect alternates between single and multi-bolt strikes for natural variation. Low intensity (1-15) creates single, rare strikes. Medium intensity (16-30) occasionally spawns double bolts. High intensity (31-60) alternates between single and 2-3 bolt strikes. Very high intensity (61+) creates dramatic storms alternating between single and 2-4 simultaneous bolts.
+
+
+
+
+ Rare (5)
+
+
+
+
+ Frequent (20)
+
+
+
+
+ Storm (50)
+
+
+
+ }
+ codes={[
+ {
+ code:
+`
+
+
+
+`,
+ language: "tsx",
+ label: "Lightning Intensity"
+ }
+ ]}
+/>
+
## Duration
Control how long particles are emitted with the `duration` prop (in seconds). After the duration, existing particles finish their animation but no new ones spawn.
@@ -718,7 +861,7 @@ Control how long particles are emitted with the `duration` prop (in seconds). Af
]}
/>
-## Hover Trigger
+## Trigger
Use `trigger="hover"` to only emit particles when hovering over the element.
@@ -779,17 +922,90 @@ Use `trigger="hover"` to only emit particles when hovering over the element.
]}
/>
+Use `trigger="click"` to toggle emission on click.
+
+
+
+ Click to toggle rain
+
+
+ }
+ codes={[
+ {
+ code:
+``,
+ language: "tsx",
+ label: "Click Trigger"
+ }
+ ]}
+/>
+
+Use `trigger="manual"` with the `active` prop to control emission from parent state.
+
+
+ }
+ codes={[
+ {
+ code:
+`"use client";
+
+import React from "react";
+import { WeatherFx, Row, ToggleButton } from "@once-ui-system/core";
+
+export function Demo() {
+ const [on, setOn] = React.useState(false);
+
+ return (
+
+ setOn((v) => !v)}>
+ {on ? "Stop" : "Start"}
+
+
+
+ );
+}
+`,
+ language: "tsx",
+ label: "Manual Trigger"
+ }
+ ]}
+/>
+
## API reference
+
+
+
+ );
+}
diff --git a/apps/docs/src/product/MatrixFxExample.tsx b/apps/docs/src/product/MatrixFxExample.tsx
new file mode 100644
index 0000000..af06233
--- /dev/null
+++ b/apps/docs/src/product/MatrixFxExample.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { Row, MatrixFx, Button } from "@once-ui-system/core";
+import React from "react";
+
+export function MatrixFxExample() {
+ const [on, setOn] = React.useState(false);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/docs/src/product/RadialGaugeExample.tsx b/apps/docs/src/product/RadialGaugeExample.tsx
new file mode 100644
index 0000000..d0410d7
--- /dev/null
+++ b/apps/docs/src/product/RadialGaugeExample.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import { RadialGauge } from "@once-ui-system/core";
+
+// Animated speed meter demo for RadialGauge docs
+export function RadialGaugeSpeedDemo() {
+ const [value, setValue] = useState(4);
+
+ useEffect(() => {
+ const timeouts: NodeJS.Timeout[] = [];
+
+ const cycle = () => {
+ timeouts.push(setTimeout(() => setValue(96), 500));
+ timeouts.push(setTimeout(() => setValue(4), 2500));
+ timeouts.push(setTimeout(cycle, 4500));
+ };
+
+ cycle();
+
+ return () => {
+ timeouts.forEach(clearTimeout);
+ };
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/apps/docs/src/product/WeatherFxExample.tsx b/apps/docs/src/product/WeatherFxExample.tsx
new file mode 100644
index 0000000..09920f3
--- /dev/null
+++ b/apps/docs/src/product/WeatherFxExample.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { Button, Row } from "@once-ui-system/core";
+import { WeatherFx } from "@once-ui-system/core";
+import React from "react";
+
+export function WeatherFxExample() {
+ const [on, setOn] = React.useState(false);
+
+ return (
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/docs/src/product/index.ts b/apps/docs/src/product/index.ts
index 2f32099..9a1d50f 100644
--- a/apps/docs/src/product/index.ts
+++ b/apps/docs/src/product/index.ts
@@ -27,4 +27,9 @@ export * from "./SwitchExample";
export * from "./TextareaExamples";
export * from "./ToastExample";
export * from "./TypeFxCustomExample";
-export * from "./Products";
\ No newline at end of file
+export * from "./Products";
+export * from "./RadialGaugeExample";
+export * from "./CelebrationFxExample";
+export * from "./WeatherFxExample";
+export * from "./MatrixFxExample";
+
diff --git a/packages/core/package.json b/packages/core/package.json
index bce62ff..a89ab50 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@once-ui-system/core",
- "version": "1.5.2",
+ "version": "1.5.3",
"description": "Once UI for Next.js NPM package",
"keywords": [
"once-ui",
diff --git a/packages/core/src/components/Avatar.tsx b/packages/core/src/components/Avatar.tsx
index a20c32a..2476eaa 100644
--- a/packages/core/src/components/Avatar.tsx
+++ b/packages/core/src/components/Avatar.tsx
@@ -4,6 +4,7 @@ import React, { forwardRef } from "react";
import { Skeleton, Icon, Text, StatusIndicator, Flex, Media } from ".";
import styles from "./Avatar.module.scss";
+import { IconName } from "@/icons";
interface AvatarProps extends React.ComponentProps {
size?: "xs" | "s" | "m" | "l" | "xl" | number;
@@ -11,6 +12,7 @@ interface AvatarProps extends React.ComponentProps {
src?: string;
loading?: boolean;
empty?: boolean;
+ icon?: IconName;
statusIndicator?: {
color: "green" | "yellow" | "red" | "gray";
};
@@ -36,7 +38,7 @@ const statusIndicatorSizeMapping: Record<"xs" | "s" | "m" | "l" | "xl", "s" | "m
const Avatar = forwardRef(
(
- { size = "m", value, src, loading, empty, statusIndicator, className, style = {}, ...rest },
+ { size = "m", value, src, loading, empty, icon, statusIndicator, className, style = {}, ...rest },
ref,
) => {
const sizeInRem = typeof size === "number" ? `${size}rem` : undefined;
@@ -75,7 +77,7 @@ const Avatar = forwardRef(
return (
{
+ type?: CelebrationType;
+ speed?: number;
+ colors?: string[];
+ intensity?: number;
+ duration?: number;
+ trigger?: "mount" | "hover" | "manual" | "click";
+ active?: boolean; // For manual trigger
+ children?: React.ReactNode;
+}
+
+interface ConfettiPiece {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ color: string;
+ rotation: number;
+ rotationSpeed: number;
+ velocityX: number;
+ velocityY: number;
+ gravity: number;
+ opacity: number;
+ shape: "rectangle" | "circle" | "triangle";
+}
+
+interface Firework {
+ x: number;
+ y: number;
+ targetY: number;
+ velocityY: number;
+ color: string;
+ exploded: boolean;
+ particles: FireworkParticle[];
+}
+
+interface FireworkParticle {
+ x: number;
+ y: number;
+ velocityX: number;
+ velocityY: number;
+ color: string;
+ opacity: number;
+ size: number;
+ life: number;
+ maxLife: number;
+}
+
+const CelebrationFx = React.forwardRef(
+ (
+ {
+ type = "confetti",
+ speed = 1,
+ colors = ["brand-solid-medium", "accent-solid-medium"],
+ intensity = 50,
+ duration,
+ trigger = "mount",
+ active = true,
+ children,
+ ...rest
+ },
+ forwardedRef,
+ ) => {
+ const containerRef = useRef(null);
+ const canvasRef = useRef(null);
+ const animationRef = useRef(undefined);
+ const particlesRef = useRef<(ConfettiPiece | Firework)[]>([]);
+ const isEmittingRef = useRef(trigger === "mount");
+ const emitStartTimeRef = useRef(Date.now());
+ const isHoveredRef = useRef(false);
+ const fireworkTimerRef = useRef(0);
+ const clickPositionRef = useRef<{ x: number; y: number } | null>(null);
+
+ useEffect(() => {
+ if (forwardedRef) {
+ if ("current" in forwardedRef) {
+ forwardedRef.current = containerRef.current;
+ } else if (typeof forwardedRef === "function") {
+ forwardedRef(containerRef.current);
+ }
+ }
+ }, [forwardedRef]);
+
+ // Handle manual trigger
+ useEffect(() => {
+ if (trigger === "manual") {
+ if (active) {
+ isEmittingRef.current = true;
+ emitStartTimeRef.current = Date.now();
+ } else {
+ isEmittingRef.current = false;
+ }
+ }
+ }, [trigger, active]);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const container = containerRef.current;
+ if (!canvas || !container) return;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ // Set canvas size
+ let canvasWidth = 0;
+ let canvasHeight = 0;
+
+ const updateSize = () => {
+ const rect = container.getBoundingClientRect();
+ canvasWidth = rect.width;
+ canvasHeight = rect.height;
+ canvas.width = rect.width * 2; // 2x for retina
+ canvas.height = rect.height * 2;
+ canvas.style.width = `${rect.width}px`;
+ canvas.style.height = `${rect.height}px`;
+ ctx.scale(2, 2); // Scale for retina
+ };
+
+ updateSize();
+ window.addEventListener("resize", updateSize);
+
+ // Parse colors - convert token names to CSS variables
+ const parsedColors = colors.map((color) => {
+ const computedColor = getComputedStyle(container).getPropertyValue(`--${color}`);
+ return computedColor || color;
+ });
+
+ // Initialize confetti
+ const initializeConfetti = () => {
+ const particles: ConfettiPiece[] = [];
+ const shapes: ("rectangle" | "circle" | "triangle")[] = ["rectangle", "circle", "triangle"];
+
+ for (let i = 0; i < intensity; i++) {
+ // Stagger particles more dramatically over a larger vertical range
+ const stagger = (i / intensity) * canvasHeight * 2;
+ particles.push({
+ x: Math.random() * canvasWidth,
+ y: -canvasHeight - Math.random() * canvasHeight - stagger,
+ width: 8 + Math.random() * 8,
+ height: 6 + Math.random() * 6,
+ color: parsedColors[Math.floor(Math.random() * parsedColors.length)],
+ rotation: Math.random() * Math.PI * 2,
+ rotationSpeed: (Math.random() - 0.5) * 0.2,
+ velocityX: (Math.random() - 0.5) * 3,
+ velocityY: (0.5 + Math.random() * 2.5) * speed,
+ gravity: (0.12 + Math.random() * 0.06) * speed,
+ opacity: 0.8 + Math.random() * 0.2,
+ shape: shapes[Math.floor(Math.random() * shapes.length)],
+ });
+ }
+
+ return particles;
+ };
+
+ // Create a new firework (explodes immediately at random or click position)
+ const createFirework = (clickPos?: { x: number; y: number }) => {
+ const firework: Firework = {
+ x: clickPos ? clickPos.x : Math.random() * canvasWidth,
+ y: clickPos ? clickPos.y : canvasHeight * (0.2 + Math.random() * 0.4),
+ targetY: 0, // Not used anymore
+ velocityY: 0,
+ color: parsedColors[Math.floor(Math.random() * parsedColors.length)],
+ exploded: true, // Start already exploded
+ particles: [],
+ };
+ // Explode immediately
+ explodeFirework(firework);
+ return firework;
+ };
+
+ // Explode firework into particles
+ const explodeFirework = (firework: Firework) => {
+ const particleCount = 30 + Math.floor(Math.random() * 20);
+ for (let i = 0; i < particleCount; i++) {
+ const angle = (Math.PI * 2 * i) / particleCount;
+ const velocity = 1 + Math.random() * 3;
+ firework.particles.push({
+ x: firework.x,
+ y: firework.y,
+ velocityX: Math.cos(angle) * velocity * speed,
+ velocityY: Math.sin(angle) * velocity * speed,
+ color: firework.color,
+ opacity: 1,
+ size: 1 + Math.random(),
+ life: 0,
+ maxLife: 60 + Math.random() * 40,
+ });
+ }
+ firework.exploded = true;
+ };
+
+ // Initialize particles only if not already initialized
+ if (particlesRef.current.length === 0 && type === "confetti") {
+ particlesRef.current = initializeConfetti();
+ }
+
+ // Animation loop
+ const animate = () => {
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight);
+
+ // Check if we should still be emitting new particles
+ const shouldEmit =
+ trigger === "hover" ? isHoveredRef.current :
+ trigger === "manual" ? active :
+ trigger === "click" ? clickPositionRef.current !== null :
+ isEmittingRef.current;
+
+ // Check duration limit
+ if (duration && isEmittingRef.current && trigger !== "manual") {
+ const elapsed = (Date.now() - emitStartTimeRef.current) / 1000;
+ if (elapsed > duration) {
+ isEmittingRef.current = false;
+ }
+ }
+
+ if (type === "confetti") {
+ // Update and draw confetti
+ (particlesRef.current as ConfettiPiece[]).forEach((piece) => {
+ // Update physics
+ piece.velocityY += piece.gravity;
+ piece.x += piece.velocityX;
+ piece.y += piece.velocityY;
+ piece.rotation += piece.rotationSpeed;
+
+ // Add some air resistance
+ piece.velocityX *= 0.99;
+
+ // Only respawn if we're still emitting
+ if (piece.y > canvasHeight + 50 && shouldEmit) {
+ piece.y = -20 - Math.random() * canvasHeight * 0.5;
+ piece.x = Math.random() * canvasWidth;
+ piece.velocityX = (Math.random() - 0.5) * 3;
+ piece.velocityY = (0.5 + Math.random() * 2.5) * speed;
+ piece.gravity = (0.12 + Math.random() * 0.06) * speed;
+ }
+
+ // Only draw if still on screen
+ if (piece.y < canvasHeight + 100) {
+ // Draw confetti piece
+ ctx.save();
+ ctx.translate(piece.x, piece.y);
+ ctx.rotate(piece.rotation);
+ ctx.globalAlpha = piece.opacity;
+ ctx.fillStyle = piece.color;
+
+ if (piece.shape === "rectangle") {
+ ctx.fillRect(-piece.width / 2, -piece.height / 2, piece.width, piece.height);
+ } else if (piece.shape === "circle") {
+ ctx.beginPath();
+ ctx.arc(0, 0, piece.width / 2, 0, Math.PI * 2);
+ ctx.fill();
+ } else if (piece.shape === "triangle") {
+ ctx.beginPath();
+ ctx.moveTo(0, -piece.height / 2);
+ ctx.lineTo(piece.width / 2, piece.height / 2);
+ ctx.lineTo(-piece.width / 2, piece.height / 2);
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ ctx.restore();
+ }
+ });
+ } else if (type === "fireworks") {
+ // Launch new fireworks
+ if (trigger === "click" && clickPositionRef.current) {
+ // Fire from click position
+ particlesRef.current.push(createFirework(clickPositionRef.current));
+ clickPositionRef.current = null;
+ } else {
+ fireworkTimerRef.current++;
+ if (shouldEmit && fireworkTimerRef.current > 20 / speed) {
+ // Launch new firework every ~20 frames
+ fireworkTimerRef.current = 0;
+ particlesRef.current.push(createFirework());
+ }
+ }
+
+ // Update and draw fireworks (explosion particles only)
+ (particlesRef.current as Firework[]).forEach((firework) => {
+ // Update and draw explosion particles
+ firework.particles.forEach((particle) => {
+ particle.life++;
+
+ if (particle.life < particle.maxLife) {
+ // Update position
+ particle.x += particle.velocityX;
+ particle.y += particle.velocityY;
+ particle.velocityY += 0.1; // Gravity
+
+ // Fade out
+ particle.opacity = 1 - particle.life / particle.maxLife;
+
+ // Draw particle
+ ctx.globalAlpha = particle.opacity;
+ ctx.fillStyle = particle.color;
+ ctx.beginPath();
+ ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ });
+ });
+
+ // Clean up dead fireworks (only when not emitting)
+ if (!shouldEmit) {
+ particlesRef.current = (particlesRef.current as Firework[]).filter((firework) => {
+ // Keep if any particles are still alive
+ return firework.particles.some(p => p.life < p.maxLife);
+ });
+ }
+ }
+
+ ctx.globalAlpha = 1;
+ animationRef.current = requestAnimationFrame(animate);
+ };
+
+ animate();
+
+ return () => {
+ window.removeEventListener("resize", updateSize);
+ if (animationRef.current) {
+ cancelAnimationFrame(animationRef.current);
+ }
+ };
+ }, [type, colors, speed, intensity, duration, trigger]);
+
+ const handleMouseEnter = () => {
+ if (trigger === "hover" && !isHoveredRef.current) {
+ isHoveredRef.current = true;
+ isEmittingRef.current = true;
+ emitStartTimeRef.current = Date.now();
+ }
+ };
+
+ const handleMouseLeave = () => {
+ if (trigger === "hover" && isHoveredRef.current) {
+ isHoveredRef.current = false;
+ }
+ };
+
+ const handleClick = (e: React.MouseEvent) => {
+ if (trigger === "click" && containerRef.current) {
+ const rect = containerRef.current.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ if (type === "confetti") {
+ // For confetti, add burst of particles from click position
+ const shapes: ("rectangle" | "circle" | "triangle")[] = ["rectangle", "circle", "triangle"];
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const parsedColors = colors.map((color) => {
+ const computedColor = getComputedStyle(containerRef.current!).getPropertyValue(`--${color}`);
+ return computedColor || color;
+ });
+
+ for (let i = 0; i < intensity; i++) {
+ const angle = (Math.PI * 2 * i) / intensity;
+ const velocity = 2 + Math.random() * 3;
+ (particlesRef.current as ConfettiPiece[]).push({
+ x,
+ y,
+ width: 8 + Math.random() * 8,
+ height: 6 + Math.random() * 6,
+ color: parsedColors[Math.floor(Math.random() * parsedColors.length)],
+ rotation: Math.random() * Math.PI * 2,
+ rotationSpeed: (Math.random() - 0.5) * 0.2,
+ velocityX: Math.cos(angle) * velocity,
+ velocityY: Math.sin(angle) * velocity - 2, // Slight upward bias
+ gravity: (0.12 + Math.random() * 0.06) * speed,
+ opacity: 0.8 + Math.random() * 0.2,
+ shape: shapes[Math.floor(Math.random() * shapes.length)],
+ });
+ }
+ } else {
+ // For fireworks, store click position
+ clickPositionRef.current = { x, y };
+ }
+ }
+ };
+
+ return (
+
+
+ {children}
+
+ );
+ },
+);
+
+CelebrationFx.displayName = "CelebrationFx";
+export { CelebrationFx };
diff --git a/packages/core/src/components/MatrixFx.tsx b/packages/core/src/components/MatrixFx.tsx
index 5c484f6..e6a7389 100644
--- a/packages/core/src/components/MatrixFx.tsx
+++ b/packages/core/src/components/MatrixFx.tsx
@@ -11,13 +11,30 @@ interface BulgeConfig {
delay?: number;
}
+interface Dot {
+ x: number;
+ y: number;
+ gridX: number;
+ gridY: number;
+ color: string;
+ baseOpacity: number;
+ distanceFromOrigin: number;
+ randomOffset: number;
+ flickerPhase: number;
+ flickerSpeed: number;
+ gridSize?: number;
+ canvasW?: number;
+ canvasH?: number;
+}
+
interface MatrixFxProps extends React.ComponentProps {
speed?: number;
colors?: string[];
size?: number;
spacing?: number;
revealFrom?: "center" | "top" | "bottom" | "left" | "right";
- trigger?: "hover" | "instant" | "mount";
+ trigger?: "hover" | "instant" | "mount" | "click" | "manual";
+ active?: boolean;
flicker?: boolean;
bulge?: BulgeConfig;
children?: React.ReactNode;
@@ -32,6 +49,7 @@ const MatrixFx = React.forwardRef(
spacing = 3,
revealFrom = "center",
trigger = "instant",
+ active = false,
flicker = false,
bulge,
children,
@@ -49,6 +67,7 @@ const MatrixFx = React.forwardRef(
const isHoveredRef = useRef(false);
const mountAnimationCompleteRef = useRef(false);
const bulgeStartTimeRef = useRef(Date.now());
+ const dotsRef = useRef([]);
useEffect(() => {
if (forwardedRef) {
@@ -101,68 +120,72 @@ const MatrixFx = React.forwardRef(
const cols = Math.ceil(paddedWidth / totalSize);
const rows = Math.ceil(paddedHeight / totalSize);
- interface Dot {
- x: number;
- y: number;
- gridX: number;
- gridY: number;
- color: string;
- baseOpacity: number;
- distanceFromOrigin: number;
- randomOffset: number;
- flickerPhase: number;
- flickerSpeed: number;
- }
+ // Only create new dots if grid doesn't exist or dimensions/size changed
+ let dots: Dot[] = dotsRef.current;
+ let maxDistance = 0;
+
+ if (dots.length === 0 || dots[0]?.gridSize !== totalSize || dots[0]?.canvasW !== canvasWidth || dots[0]?.canvasH !== canvasHeight) {
+ // Create new dot grid
+ dots = [];
+
+ for (let row = 0; row < rows; row++) {
+ for (let col = 0; col < cols; col++) {
+ const x = col * totalSize + size / 2 - maxDisplacement;
+ const y = row * totalSize + size / 2 - maxDisplacement;
+
+ // Calculate distance from reveal origin
+ let distanceFromOrigin = 0;
+ const centerX = canvasWidth / 2;
+ const centerY = canvasHeight / 2;
+
+ switch (revealFrom) {
+ case "center":
+ const dx = x - centerX;
+ const dy = y - centerY;
+ distanceFromOrigin = Math.sqrt(dx * dx + dy * dy);
+ break;
+ case "top":
+ distanceFromOrigin = y;
+ break;
+ case "bottom":
+ distanceFromOrigin = canvasHeight - y;
+ break;
+ case "left":
+ distanceFromOrigin = x;
+ break;
+ case "right":
+ distanceFromOrigin = canvasWidth - x;
+ break;
+ }
- const dots: Dot[] = [];
-
- for (let row = 0; row < rows; row++) {
- for (let col = 0; col < cols; col++) {
- const x = col * totalSize + size / 2 - maxDisplacement;
- const y = row * totalSize + size / 2 - maxDisplacement;
-
- // Calculate distance from reveal origin
- let distanceFromOrigin = 0;
- const centerX = canvasWidth / 2;
- const centerY = canvasHeight / 2;
-
- switch (revealFrom) {
- case "center":
- const dx = x - centerX;
- const dy = y - centerY;
- distanceFromOrigin = Math.sqrt(dx * dx + dy * dy);
- break;
- case "top":
- distanceFromOrigin = y;
- break;
- case "bottom":
- distanceFromOrigin = canvasHeight - y;
- break;
- case "left":
- distanceFromOrigin = x;
- break;
- case "right":
- distanceFromOrigin = canvasWidth - x;
- break;
+ dots.push({
+ x,
+ y,
+ gridX: col,
+ gridY: row,
+ color: parsedColors[Math.floor(Math.random() * parsedColors.length)],
+ baseOpacity: 0.3 + Math.random() * 0.7,
+ distanceFromOrigin,
+ randomOffset: Math.random() * 0.3,
+ flickerPhase: Math.random() * Math.PI * 2,
+ flickerSpeed: 0.8 + Math.random() * 0.4,
+ gridSize: totalSize,
+ canvasW: canvasWidth,
+ canvasH: canvasHeight,
+ });
}
-
- dots.push({
- x,
- y,
- gridX: col,
- gridY: row,
- color: parsedColors[Math.floor(Math.random() * parsedColors.length)],
- baseOpacity: 0.3 + Math.random() * 0.7,
- distanceFromOrigin,
- randomOffset: Math.random() * 0.3,
- flickerPhase: Math.random() * Math.PI * 2, // Random starting point
- flickerSpeed: 0.8 + Math.random() * 0.4, // Random speed between 0.8 and 1.2
- });
}
- }
- // Find max distance for normalization
- const maxDistance = Math.max(...dots.map((d) => d.distanceFromOrigin));
+ // Find max distance for normalization
+ maxDistance = Math.max(...dots.map((d) => d.distanceFromOrigin));
+ dotsRef.current = dots;
+ } else {
+ // Update colors on existing dots
+ dots.forEach((dot) => {
+ dot.color = parsedColors[Math.floor(Math.random() * parsedColors.length)];
+ });
+ maxDistance = Math.max(...dots.map((d) => d.distanceFromOrigin));
+ }
// Animation loop
@@ -475,9 +498,10 @@ const MatrixFx = React.forwardRef(
return;
}
- // For hover trigger with animation
- if (isHoveredRef.current) {
- // Revealing animation with explosive easing
+ // For hover, click, and manual triggers with animation
+ if (trigger === "hover" || trigger === "click" || trigger === "manual") {
+ if (isHoveredRef.current) {
+ // Revealing animation with explosive easing
const now = Date.now();
const elapsed = (now - revealStartTimeRef.current) / 1000;
// Cubic easing: starts very slow, then explodes
@@ -622,6 +646,7 @@ const MatrixFx = React.forwardRef(
hideStartProgressRef.current = 0;
}
}
+ }
}
ctx.globalAlpha = 1;
@@ -638,6 +663,39 @@ const MatrixFx = React.forwardRef(
};
}, [colors, size, spacing, speed, revealFrom, trigger, flicker, bulge]);
+ // Manual trigger control via `active` prop
+ useEffect(() => {
+ if (trigger !== "manual") return;
+ const now = Date.now();
+ if (active) {
+ // Mimic mouse enter
+ if (hideStartProgressRef.current > 0) {
+ const hideElapsed = (now - hideStartTimeRef.current) / 1000;
+ const hideSpeed = speed * 6;
+ const hideProgress = Math.pow(hideElapsed, 2) * hideSpeed;
+ const currentProgress = Math.max(0, hideStartProgressRef.current - hideProgress);
+ const effectiveElapsed = Math.pow(currentProgress / (speed * 3), 1 / 3);
+ const simulatedStartTime = now - effectiveElapsed * 1000;
+ revealStartTimeRef.current = simulatedStartTime;
+ } else {
+ revealStartTimeRef.current = now;
+ }
+ if (bulge && !bulge.repeat) {
+ bulgeStartTimeRef.current = now;
+ }
+ isHoveredRef.current = true;
+ hideStartProgressRef.current = 0;
+ } else {
+ // Mimic mouse leave
+ if (isHoveredRef.current) {
+ const currentProgress = maxRevealProgressRef.current;
+ hideStartTimeRef.current = Date.now();
+ hideStartProgressRef.current = currentProgress;
+ isHoveredRef.current = false;
+ }
+ }
+ }, [active, trigger, speed, bulge]);
+
const handleMouseEnter = () => {
if (trigger === "hover" && !isHoveredRef.current) {
const now = Date.now();
@@ -686,6 +744,36 @@ const MatrixFx = React.forwardRef(
}
};
+ const handleClick = () => {
+ if (trigger !== "click") return;
+ if (!isHoveredRef.current) {
+ // Enter
+ const now = Date.now();
+ if (hideStartProgressRef.current > 0) {
+ const hideElapsed = (now - hideStartTimeRef.current) / 1000;
+ const hideSpeed = speed * 6;
+ const hideProgress = Math.pow(hideElapsed, 2) * hideSpeed;
+ const currentProgress = Math.max(0, hideStartProgressRef.current - hideProgress);
+ const effectiveElapsed = Math.pow(currentProgress / (speed * 3), 1 / 3);
+ const simulatedStartTime = now - effectiveElapsed * 1000;
+ revealStartTimeRef.current = simulatedStartTime;
+ } else {
+ revealStartTimeRef.current = now;
+ }
+ if (bulge && !bulge.repeat) {
+ bulgeStartTimeRef.current = now;
+ }
+ isHoveredRef.current = true;
+ hideStartProgressRef.current = 0;
+ } else {
+ // Leave
+ const currentProgress = maxRevealProgressRef.current;
+ hideStartTimeRef.current = Date.now();
+ hideStartProgressRef.current = currentProgress;
+ isHoveredRef.current = false;
+ }
+ };
+
return (
(
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
+ onClick={handleClick}
{...rest}
>