From 1595a4137c37dafdb115a6d14771aad674476300 Mon Sep 17 00:00:00 2001 From: TweenzY-Y Date: Tue, 21 Jul 2026 22:42:32 +0200 Subject: [PATCH] feat: add CSS Cubic-Bezier easing curve generator utility --- src/App.jsx | 5 + src/config/sidebarSections.js | 6 + src/pages/DevUtilities/DevUtilities.jsx | 24 + .../devutilities/CubicBezierGenerator.jsx | 536 ++++++++++++++++++ 4 files changed, 571 insertions(+) create mode 100644 src/pages/DevUtilities/devutilities/CubicBezierGenerator.jsx diff --git a/src/App.jsx b/src/App.jsx index 8f6d002..06d5fda 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -44,6 +44,7 @@ import CssAnimationGenerator from "./pages/DevUtilities/devutilities/CssAnimatio import CssGlassmorphismPlayground from "./pages/DevUtilities/devutilities/CssGlassmorphismPlayground"; import CssGradientGenerator from "./pages/DevUtilities/devutilities/CssGradientGenerator"; import CssUnitConverter from "./pages/DevUtilities/devutilities/CssUnitConverter"; +import CubicBezierGenerator from "./pages/DevUtilities/devutilities/CubicBezierGenerator"; import DevUtilities from "./pages/DevUtilities/DevUtilities"; import DiffChecker from "./pages/DevUtilities/devutilities/DiffChecker"; import FlexboxGridGenerator from "./pages/DevUtilities/devutilities/FlexboxGridGenerator"; @@ -397,6 +398,10 @@ function AppInner({ toggleHUD, hudVisible }) { path="/devutilities/css-animation" element={} /> + } + /> } /> { ), }, + { + title: "CSS Cubic-Bezier Generator", + description: + "Design custom transition easing curves visually and preview animations in real time.", + keywords: "cubic bezier easing curve timing function transition animation", + path: "/devutilities/cubic-bezier", + icon: ( + + + + + + ), + }, { title: "CSS Gradient Generator", description: "Create beautiful CSS gradients with live preview", diff --git a/src/pages/DevUtilities/devutilities/CubicBezierGenerator.jsx b/src/pages/DevUtilities/devutilities/CubicBezierGenerator.jsx new file mode 100644 index 0000000..2297640 --- /dev/null +++ b/src/pages/DevUtilities/devutilities/CubicBezierGenerator.jsx @@ -0,0 +1,536 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; +import { FaArrowLeft, FaCopy, FaPlay, FaStop, FaUndo } from "react-icons/fa"; +import { useTheme } from "../../../context/ThemeContext"; + +const GRAPH = { minX: -0.3, minY: -0.6, width: 1.6, height: 2.2 }; +const GRID_LINES = [0.25, 0.5, 0.75]; +const GRAB_RADIUS_PX = 28; + +const CURVE_COLOR = "#6366f1"; // indigo-500 +const HANDLE_COLOR = "#ec4899"; // pink-500 + +const clamp = (n, min, max) => Math.min(max, Math.max(min, n)); +const round2 = (n) => Math.round(n * 100) / 100; + +const toSvgPoint = ([time, progress]) => ({ x: time, y: 1 - progress }); + +const toClientPoint = ({ x, y }, rect) => ({ + x: rect.left + ((x - GRAPH.minX) / GRAPH.width) * rect.width, + y: rect.top + ((y - GRAPH.minY) / GRAPH.height) * rect.height, +}); + +const toControlPoint = (clientX, clientY, svg) => { + if (!svg) return null; + + const rect = svg.getBoundingClientRect(); + const x = GRAPH.minX + ((clientX - rect.left) / rect.width) * GRAPH.width; + const y = GRAPH.minY + ((clientY - rect.top) / rect.height) * GRAPH.height; + + return { time: round2(clamp(x, 0, 1)), progress: round2(1 - y) }; +}; + +const PRESETS = [ + { name: "linear", value: [0, 0, 1, 1] }, + { name: "ease", value: [0.25, 0.1, 0.25, 1] }, + { name: "ease-in", value: [0.42, 0, 1, 1] }, + { name: "ease-out", value: [0, 0, 0.58, 1] }, + { name: "ease-in-out", value: [0.42, 0, 0.58, 1] }, +]; + +const DEFAULT_BEZIER = PRESETS[4].value; +const DEFAULT_COMPARE = "linear"; +const DEFAULT_DURATION = 0.6; + +const toCubicBezier = (b) => `cubic-bezier(${b[0]}, ${b[1]}, ${b[2]}, ${b[3]})`; +const presetValue = (name) => PRESETS.find((p) => p.name === name).value; +const sameBezier = (a, b) => a.every((n, i) => n === b[i]); + +const THEME = { + light: { + wrapper: "bg-[#F8F9FA] text-zinc-900", + heading: "text-zinc-900", + subtext: "text-zinc-500", + card: "bg-white border-zinc-200/85 shadow-sm", + activeBtn: "bg-zinc-900 text-white border-zinc-900", + secondaryBtn: + "bg-white text-zinc-800 border-zinc-200 hover:bg-zinc-50 transition-all duration-200", + backLink: + "bg-white border-neutral-200 text-neutral-600 hover:text-black hover:border-neutral-350", + codeBox: "bg-zinc-900 text-zinc-100 border-zinc-800", + canvasBg: "#fafafa", + track: "bg-zinc-100", + grid: "#e4e4e7", + axis: "#a1a1aa", + }, + dark: { + wrapper: "bg-[#090A0F] text-zinc-100", + heading: "text-zinc-100", + subtext: "text-zinc-500", + card: "bg-zinc-900/50 border-zinc-800/85 backdrop-blur-md shadow-md", + activeBtn: "bg-white text-zinc-900 border-white", + secondaryBtn: + "bg-zinc-800/50 text-zinc-300 border-zinc-700 hover:bg-zinc-700/50 transition-all duration-200", + backLink: + "bg-zinc-800/80 border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-600", + codeBox: "bg-black/40 text-emerald-400 border-zinc-800/80 font-mono", + canvasBg: "#18181b", + track: "bg-zinc-800", + grid: "#3f3f46", + axis: "#52525b", + }, +}; + +const useThemeTokens = () => { + const { dark } = useTheme(); + return dark ? THEME.dark : THEME.light; +}; + +const PREVIEW_KEYFRAMES = + "@keyframes dtBezierRun { from { left: 0 } to { left: calc(100% - 1.5rem) } }"; + +function useControlPointDrag(svgRef, points, onChange) { + const draggingRef = useRef(null); // "p1" | "p2" | null + + const onPointerDown = (e) => { + const svg = svgRef.current; + + if (!svg) return; + + const rect = svg.getBoundingClientRect(); + const distanceTo = (point) => { + const { x, y } = toClientPoint(point, rect); + + return Math.hypot(e.clientX - x, e.clientY - y); + }; + const d1 = distanceTo(points.p1); + const d2 = distanceTo(points.p2); + + if (Math.min(d1, d2) > GRAB_RADIUS_PX) return; + + e.preventDefault(); + draggingRef.current = d1 <= d2 ? "p1" : "p2"; + }; + + useEffect(() => { + const handleMove = (e) => { + const which = draggingRef.current; + + if (!which) return; + + const point = toControlPoint(e.clientX, e.clientY, svgRef.current); + + if (!point) return; + + onChange((prev) => + which === "p1" + ? [point.time, point.progress, prev[2], prev[3]] + : [prev[0], prev[1], point.time, point.progress], + ); + }; + const stop = () => { + draggingRef.current = null; + }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); + + return () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", stop); + window.removeEventListener("pointercancel", stop); + }; + }, [svgRef, onChange]); + + return onPointerDown; +} + +export default function CubicBezierGenerator() { + const t = useThemeTokens(); + const [bezier, setBezier] = useState(DEFAULT_BEZIER); + const [comparePreset, setComparePreset] = useState(DEFAULT_COMPARE); + const [duration, setDuration] = useState(DEFAULT_DURATION); + const [playing, setPlaying] = useState(false); + + const cubicValue = toCubicBezier(bezier); + const cssOutput = `.custom-transition {\n transition: all ${duration}s ${cubicValue};\n}`; + + const reset = () => { + setBezier([...DEFAULT_BEZIER]); + setComparePreset(DEFAULT_COMPARE); + setDuration(DEFAULT_DURATION); + setPlaying(false); + }; + + const copyCss = () => { + navigator.clipboard.writeText(cssOutput); + toast.success("Copied CSS to clipboard!"); + }; + + return ( +
+ CSS Cubic-Bezier Generator — DevTasks + + +
+ + +
+ {/* Left / Top: curve editor */} +
+ + setBezier([...v])} /> +
+ + {/* Right / Bottom: preview & output */} +
+ + setPlaying((p) => !p)} + /> + +
+
+
+
+ ); +} + +function Panel({ className = "", children }) { + const t = useThemeTokens(); + return ( +
+ {children} +
+ ); +} + +function SectionTitle({ children }) { + const t = useThemeTokens(); + return ( +

+ {children} +

+ ); +} + +function PageHeader() { + const t = useThemeTokens(); + return ( +
+ + + +
+

+ CSS Cubic-Bezier Generator +

+

+ Design custom transition easing curves visually and preview animations + in real time. Fully offline. +

+
+
+ ); +} + +function EasingGraph({ value, onChange, onReset }) { + const t = useThemeTokens(); + const svgRef = useRef(null); + const [x1, y1, x2, y2] = value; + + const points = { + start: { x: 0, y: 1 }, + end: { x: 1, y: 0 }, + p1: toSvgPoint([x1, y1]), + p2: toSvgPoint([x2, y2]), + }; + const curve = `M 0 1 C ${points.p1.x} ${points.p1.y}, ${points.p2.x} ${points.p2.y}, 1 0`; + + const onPointerDown = useControlPointDrag(svgRef, points, onChange); + + return ( + +
+ Easing Graph + +
+ + + {/* Overshoot zones above and below the unit square */} + + + + {/* Gridlines */} + {GRID_LINES.map((g) => ( + + ))} + {GRID_LINES.map((g) => ( + + ))} + + {/* Unit square (start / end baselines) */} + + + + + + + + + + + + +
+ cubic-bezier( + + {x1}, {y1}, {x2}, {y2} + + ) +
+
+ ); +} + +function GuideLine({ from, to }) { + return ( + + ); +} + +function Anchor({ point, color }) { + return ; +} + +function Handle({ point }) { + return ( + + ); +} + +function EasingPresets({ value, onSelect }) { + const t = useThemeTokens(); + return ( + + Easing Presets +
+ {PRESETS.map((preset) => { + const active = sameBezier(preset.value, value); + return ( + + ); + })} +
+
+ ); +} + +function DurationSlider({ value, onChange }) { + const t = useThemeTokens(); + return ( + +
+ Duration + + {value.toFixed(1)}s + +
+ onChange(Number(e.target.value))} + className="w-full accent-indigo-500 h-1.5 bg-zinc-200 dark:bg-zinc-800 rounded-lg cursor-pointer" + /> +
+ ); +} + +function AnimationPreview({ + cubicValue, + comparePreset, + onCompareChange, + duration, + playing, + onTogglePlay, +}) { + const t = useThemeTokens(); + return ( + + + +
+ Animation Preview + +
+ + + Custom + + } + /> + + + + Comparison + + + + } + /> + +

+ Press Play to loop both blocks and compare the custom curve against the + selected preset. Adjust the curve or duration while it runs. +

+
+ ); +} + +function PlayButton({ playing, onClick }) { + return ( + + ); +} + +function PreviewTrack({ label, ballClass, timing, duration, playing }) { + const t = useThemeTokens(); + const style = playing + ? { animation: `dtBezierRun ${duration}s ${timing} infinite` } + : undefined; + return ( +
+ {label} +
+
+
+
+ ); +} + +function CssOutput({ css, onCopy }) { + const t = useThemeTokens(); + return ( + + CSS Output +
+
+          {css}
+        
+ +
+
+ ); +}