├── selene.toml ├── src ├── React.lua ├── Promise.lua ├── Animations │ ├── Base.lua │ ├── Symbols.luau │ ├── init.lua │ └── Types │ │ ├── Spring.lua │ │ └── Tween.lua ├── init.lua ├── Utility │ ├── ReactUtil.lua │ ├── LinearValue.lua │ └── SpringValue.lua ├── Hooks │ ├── useBindings.lua │ ├── useTween.lua │ ├── useSpring.lua │ ├── useAnimation.lua │ ├── useGroupAnimation.lua │ └── useSequenceAnimation.lua └── Components │ └── DynamicList.lua ├── default.project.json ├── .gitignore ├── model.project.json ├── aftman.toml ├── wally.toml ├── test.project.json ├── test ├── init.story.lua ├── init.client.lua └── Test.lua ├── LICENSE.md └── README.md /selene.toml: -------------------------------------------------------------------------------- 1 | std = "roblox" 2 | -------------------------------------------------------------------------------- /src/React.lua: -------------------------------------------------------------------------------- 1 | return require(script.Parent.Parent.React) 2 | -------------------------------------------------------------------------------- /src/Promise.lua: -------------------------------------------------------------------------------- 1 | return require(script.Parent.Parent.Promise) 2 | -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-flow", 3 | "tree": { 4 | "$path": "src" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Roblox Studio lock files 2 | /*.rbxlx.lock 3 | /*.rbxl.lock 4 | Packages/ 5 | roblox.yml 6 | wally.lock 7 | sourcemap.json -------------------------------------------------------------------------------- /model.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-flow", 3 | "tree": { 4 | "$className": "Model", 5 | "ReactFlow": { 6 | "$path": "src" 7 | }, 8 | "Packages": { 9 | "$path": "Packages" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Animations/Base.lua: -------------------------------------------------------------------------------- 1 | local BaseAnimation = {} 2 | 3 | function BaseAnimation.new() 4 | local self = {} 5 | 6 | self.listener = nil 7 | self.SetListener = function(_, listener: (any) -> ()) 8 | self.listener = listener 9 | end 10 | 11 | return self 12 | end 13 | 14 | return BaseAnimation 15 | -------------------------------------------------------------------------------- /aftman.toml: -------------------------------------------------------------------------------- 1 | # This file lists tools managed by Aftman, a cross-platform toolchain manager. 2 | # For more information, see https://github.com/LPGhatguy/aftman 3 | 4 | # To add a new tool, add an entry to this table. 5 | [tools] 6 | rojo = "rojo-rbx/rojo@7.4.1" 7 | wally = "upliftgames/wally@0.3.2" 8 | selene = "kampfkarren/selene@0.27.1" 9 | -------------------------------------------------------------------------------- /src/Animations/Symbols.luau: -------------------------------------------------------------------------------- 1 | local function symbol(name: string) 2 | local object = {} 3 | 4 | setmetatable(object, { 5 | __tostring = `Symbol({name})`, 6 | }) 7 | 8 | return object 9 | end 10 | 11 | local SPRING_SYMBOL = symbol("Spring") 12 | local TWEEN_SYMBOL = symbol("Tween") 13 | 14 | return { 15 | ["Spring"] = SPRING_SYMBOL, 16 | ["Tween"] = TWEEN_SYMBOL, 17 | 18 | [SPRING_SYMBOL] = "Spring", 19 | [TWEEN_SYMBOL] = "Tween", 20 | } 21 | -------------------------------------------------------------------------------- /src/Animations/init.lua: -------------------------------------------------------------------------------- 1 | local Symbol = require(script.Symbols) 2 | 3 | local Animations = { 4 | Spring = require(script.Types.Spring), 5 | Tween = require(script.Types.Tween), 6 | } 7 | 8 | local function fromDefinition(definitions) 9 | local animationSymbol, animationProps = definitions[1], definitions[2] 10 | local animationType = Symbol[animationSymbol] 11 | 12 | return Animations[animationType].new(animationProps) 13 | end 14 | 15 | Animations.fromDefinition = fromDefinition 16 | 17 | return Animations 18 | -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "outofbears/react-flow" 3 | description = "React Animation Library for Roblox" 4 | version = "0.2.0" 5 | authors = ["Jack Fox "] 6 | homepage = "https://github.com/outofbears/react-flow" 7 | registry = "https://github.com/UpliftGames/wally-index" 8 | repository = "https://github.com/outofbears/react-flow" 9 | realm = "shared" 10 | 11 | [dependencies] 12 | React = "jsdotlua/react@17.2.1" 13 | ReactRoblox = "jsdotlua/react-roblox@17.2.1" 14 | Promise = "evaera/promise@4.0.0" 15 | -------------------------------------------------------------------------------- /src/init.lua: -------------------------------------------------------------------------------- 1 | local Animations = require(script.Animations) 2 | 3 | return { 4 | Tween = Animations.Tween.definition, 5 | Spring = Animations.Spring.definition, 6 | 7 | useAnimation = require(script.Hooks.useAnimation), 8 | useGroupAnimation = require(script.Hooks.useGroupAnimation), 9 | useSequenceAnimation = require(script.Hooks.useSequenceAnimation), 10 | 11 | useSpring = require(script.Hooks.useSpring), 12 | useTween = require(script.Hooks.useTween), 13 | 14 | useBindings = require(script.Hooks.useBindings), 15 | 16 | DynamicList = require(script.Components.DynamicList), 17 | } 18 | -------------------------------------------------------------------------------- /test.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "React-Flow Animation Test", 3 | "tree": { 4 | "$className": "DataModel", 5 | "ReplicatedStorage": { 6 | "$className": "ReplicatedStorage", 7 | 8 | "Packages": { 9 | "$path": "Packages" 10 | }, 11 | 12 | "ReactAnimation": { 13 | "$path": "src" 14 | } 15 | }, 16 | "StarterPlayer": { 17 | "$className": "StarterPlayer", 18 | "StarterPlayerScripts": { 19 | "$className": "StarterPlayerScripts", 20 | 21 | "test": { 22 | "$path": "test" 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/init.story.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local Packages = ReplicatedStorage.Packages 3 | 4 | local React = require(Packages.React) 5 | local ReactRoblox = require(Packages.ReactRoblox) 6 | local createElement = React.createElement 7 | 8 | return function(container) 9 | local ReactAnimation = ReplicatedStorage.ReactAnimation 10 | ReactAnimation.Parent = Packages 11 | 12 | local Test = require(script.Parent.Test) 13 | local root = ReactRoblox.createRoot(container) 14 | root:render(createElement(Test)) 15 | 16 | return function() 17 | ReactAnimation.Parent = ReplicatedStorage 18 | root:unmount() 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/init.client.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local Players = game:GetService("Players") 3 | local LocalPlayer = Players.LocalPlayer 4 | 5 | local Packages = ReplicatedStorage:WaitForChild("Packages") 6 | ReplicatedStorage.ReactAnimation.Parent = Packages 7 | 8 | local Test = require(script.Test) 9 | 10 | local React = require(Packages.React) 11 | local ReactRoblox = require(Packages.ReactRoblox) 12 | local createElement = React.createElement 13 | 14 | if not LocalPlayer.Character then 15 | LocalPlayer.CharacterAdded:Wait() 16 | task.wait(4) 17 | end 18 | 19 | local root = ReactRoblox.createRoot(LocalPlayer:WaitForChild("PlayerGui")) 20 | root:render(createElement("ScreenGui", {}, { Test = createElement(Test) })) 21 | -------------------------------------------------------------------------------- /src/Utility/ReactUtil.lua: -------------------------------------------------------------------------------- 1 | -- ReactUtil provides lightweight helpers for dealing with React element descriptors. 2 | -- Deep copying a React element (as previously done) can erase Luau's refined type info 3 | -- and produce 'unknown' for consumers. For reconciliation we only need to ensure 4 | -- that a new table reference exists when we conceptually changed props. 5 | 6 | local function updateReactChild(child: any) 7 | -- If child is nil or not a table, just return it. 8 | if type(child) ~= "table" then 9 | return child 10 | end 11 | 12 | -- Shallow copy preserves element shape and keeps type field intact. 13 | local cloned = {} 14 | for k, v in child do 15 | cloned[k] = v 16 | end 17 | return cloned 18 | end 19 | 20 | return { 21 | updateReactChild = updateReactChild, 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jack Fox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/Hooks/useBindings.lua: -------------------------------------------------------------------------------- 1 | local React = require(script.Parent.Parent.React) 2 | local useEffect = React.useEffect 3 | -- TODO: Remove this when we have a better way to subscribe to bindings 4 | local subscribeToBinding = React.__subscribeToBinding 5 | 6 | local function useBindings(callback: (any...) -> (), bindings: { any }, deps: { table }) 7 | useEffect(function() 8 | local disconnects = {} 9 | local values = {} 10 | 11 | local running = true 12 | 13 | for i, binding in bindings do 14 | values[i] = binding:getValue() 15 | end 16 | 17 | for i, binding in bindings do 18 | local idx = i 19 | 20 | disconnects[idx] = subscribeToBinding(binding, function(newValue) 21 | if not running then 22 | warn("Binding updated after unmount") 23 | return 24 | end 25 | 26 | if typeof(newValue) ~= "table" and values[idx] == newValue then 27 | return 28 | end 29 | 30 | values[idx] = newValue 31 | callback(unpack(values)) 32 | end) 33 | end 34 | 35 | callback(unpack(values)) 36 | 37 | return function() 38 | for _, disconnect in disconnects do 39 | disconnect() 40 | end 41 | 42 | running = false 43 | table.clear(disconnects) 44 | table.clear(values) 45 | end 46 | end, { unpack(bindings), unpack(deps or {}) }) 47 | end 48 | 49 | return useBindings 50 | -------------------------------------------------------------------------------- /src/Hooks/useTween.lua: -------------------------------------------------------------------------------- 1 | local Tween = require(script.Parent.Parent.Animations.Types.Tween) 2 | local React = require(script.Parent.Parent.React) 3 | 4 | local useMemo = React.useMemo 5 | local useEffect = React.useEffect 6 | local useBinding = React.useBinding 7 | 8 | local function useTween(props: Tween.TweenProperties) 9 | local binding, update = useBinding(props.start) 10 | 11 | local controller = useMemo(function() 12 | local tween = Tween.new(props) 13 | 14 | return { 15 | tween = tween, 16 | 17 | start = function(subProps: Tween.TweenProperties, immediate: boolean?) 18 | assert(typeof(subProps) == "table", "useTween expects a table of properties") 19 | 20 | tween.props.info = subProps.info or tween.props.info 21 | tween.props.start = subProps.start or binding:getValue() 22 | tween.props.target = subProps.target or tween.props.target 23 | tween.props.startImmediate = subProps.startImmediate or tween.props.startImmediate 24 | tween.props.delay = subProps.delay or tween.props.delay 25 | tween:Play(subProps.start or binding:getValue(), immediate) 26 | end, 27 | 28 | stop = function() 29 | tween:Stop() 30 | end, 31 | } 32 | end, {}) 33 | 34 | useEffect(function() 35 | return function() 36 | controller.stop() 37 | end 38 | end, {}) 39 | 40 | controller.tween:SetListener(update) 41 | 42 | return binding, controller.start, controller.stop 43 | end 44 | 45 | return useTween 46 | -------------------------------------------------------------------------------- /src/Hooks/useSpring.lua: -------------------------------------------------------------------------------- 1 | local SpringValue = require(script.Parent.Parent.Utility.SpringValue) 2 | local Spring = require(script.Parent.Parent.Animations.Types.Spring) 3 | 4 | local React = require(script.Parent.Parent.React) 5 | local useBinding = React.useBinding 6 | local useMemo = React.useMemo 7 | local useEffect = React.useEffect 8 | 9 | local function useSpring(props: Spring.SpringProperties) 10 | local binding, update = useBinding(props.start) 11 | local controller = useMemo(function() 12 | local spring = SpringValue.new(props.start, props.speed, props.damper) 13 | 14 | return { 15 | spring = spring, 16 | 17 | start = function(subProps: Spring.SpringProperties, immediate: boolean?) 18 | assert(typeof(subProps) == "table", "useSpring expects a table of properties") 19 | 20 | spring:SetImmediate(immediate) 21 | 22 | if subProps.delay then 23 | spring:SetDelay(subProps.delay) 24 | end 25 | 26 | if subProps.target then 27 | spring:SetGoal(subProps.target) 28 | end 29 | 30 | if subProps.start then 31 | spring:SetValue(subProps.start) 32 | end 33 | 34 | if subProps.force then 35 | spring:Impulse(subProps.force) 36 | end 37 | 38 | if subProps.damper then 39 | spring:SetDamper(subProps.damper) 40 | end 41 | 42 | if subProps.speed then 43 | spring:SetSpeed(subProps.speed) 44 | end 45 | 46 | if subProps.target or subProps.start or subProps.force then 47 | if not spring:Playing() then 48 | spring:Run() 49 | end 50 | end 51 | end, 52 | 53 | stop = function() 54 | if spring:Playing() then 55 | spring:Stop() 56 | end 57 | end, 58 | } 59 | end, {}) 60 | 61 | useEffect(function() 62 | local spring = controller.spring 63 | 64 | return function() 65 | spring:Stop() 66 | end 67 | end, {}) 68 | 69 | controller.spring:SetUpdater(update) 70 | 71 | return binding, controller.start, controller.stop 72 | end 73 | 74 | return useSpring 75 | -------------------------------------------------------------------------------- /src/Components/DynamicList.lua: -------------------------------------------------------------------------------- 1 | local React = require(script.Parent.Parent.React) 2 | local ReactUtil = require(script.Parent.Parent.Utility.ReactUtil) 3 | 4 | local createElement = React.createElement 5 | local useState = React.useState 6 | local useEffect = React.useEffect 7 | local memo = React.memo 8 | local cloneElement = React.cloneElement 9 | 10 | -- DynamicList reconciles a dictionary of keyed children. 11 | -- Fixes: previous code wrapped ALL children with removal props each render causing churn & potential loops. 12 | -- Strategy: only wrap children flagged for removal; avoid mutating props; bail out when no change. 13 | local function DynamicList(props: { children: {} }) 14 | local children = props.children 15 | local list, setList = useState({}) 16 | 17 | useEffect(function() 18 | setList(function(prevState) 19 | local nextState = table.clone(prevState) 20 | local changed = false 21 | 22 | -- Add or update active children 23 | for key, child in children do 24 | local updated = ReactUtil.updateReactChild(child) 25 | if nextState[key] ~= updated then 26 | nextState[key] = updated 27 | changed = true 28 | end 29 | end 30 | 31 | -- Handle removals: mark existing entries not in children 32 | for key, existing in nextState do 33 | if children[key] == nil and existing ~= nil then 34 | if not (existing.props and existing.props.remove) then 35 | local wrapped = cloneElement(existing, { 36 | remove = true, 37 | destroy = function() 38 | setList(function(currentState) 39 | local cloned = table.clone(currentState) 40 | cloned[key] = nil 41 | return cloned 42 | end) 43 | end, 44 | }) 45 | nextState[key] = ReactUtil.updateReactChild(wrapped) 46 | changed = true 47 | end 48 | end 49 | end 50 | 51 | if changed then 52 | return nextState 53 | end 54 | return prevState 55 | end) 56 | end, { children }) 57 | 58 | return createElement(React.Fragment, {}, list) 59 | end 60 | 61 | return memo(DynamicList) 62 | -------------------------------------------------------------------------------- /src/Hooks/useAnimation.lua: -------------------------------------------------------------------------------- 1 | local Promise = require(script.Parent.Parent.Promise) 2 | local Animations = require(script.Parent.Parent.Animations) 3 | 4 | local React = require(script.Parent.Parent.React) 5 | local useMemo = React.useMemo 6 | 7 | export type AnimationProps = { 8 | [string]: any, 9 | } 10 | 11 | local Animation = {} 12 | Animation.__index = Animation 13 | 14 | function Animation.new(props: AnimationProps) 15 | local self = setmetatable({}, Animation) 16 | local animations = {} 17 | 18 | for name, animation in props do 19 | animations[name] = Animations.fromDefinition(animation) 20 | end 21 | 22 | self.playing = false 23 | self.listener = nil 24 | self.animation = animations 25 | 26 | for name, animation in animations do 27 | animation:SetListener(function(value) 28 | if self.listener then 29 | self.listener(name, value) 30 | end 31 | end) 32 | end 33 | 34 | return self 35 | end 36 | 37 | function Animation:SetListener(listener: (string, any) -> ()) 38 | self.listener = listener 39 | end 40 | 41 | function Animation:Play(fromProps: AnimationProps, immediate: boolean?) 42 | if self.playing then 43 | self:Stop() 44 | end 45 | 46 | local animation = Promise.new(function(resolve, _, onCancel) 47 | local promises = {} 48 | 49 | for name, animatable in self.animation do 50 | local animationPromise = animatable:Play(fromProps[name], immediate) 51 | table.insert(promises, animationPromise) 52 | end 53 | 54 | local awaiter = Promise.all(promises):andThen(resolve) 55 | 56 | onCancel(function() 57 | awaiter:cancel() 58 | 59 | for _, promise in promises do 60 | promise:cancel() 61 | end 62 | end) 63 | end) 64 | 65 | self.playing = true 66 | self.player = animation 67 | 68 | return animation 69 | end 70 | 71 | function Animation:Stop() 72 | if not self.playing then 73 | return 74 | end 75 | 76 | if self.player then 77 | self.player:cancel() 78 | self.player = nil 79 | end 80 | 81 | self.playing = false 82 | end 83 | 84 | local function useAnimation(props: AnimationProps) 85 | return useMemo(function() 86 | return Animation.new(props) 87 | end, {}) 88 | end 89 | 90 | return useAnimation 91 | -------------------------------------------------------------------------------- /src/Animations/Types/Spring.lua: -------------------------------------------------------------------------------- 1 | local BaseAnimation = require(script.Parent.Parent.Base) 2 | local Promise = require(script.Parent.Parent.Parent.Promise) 3 | local SpringValue = require(script.Parent.Parent.Parent.Utility.SpringValue) 4 | local Symbols = require(script.Parent.Parent.Symbols) 5 | 6 | local Spring = {} 7 | Spring.__index = Spring 8 | 9 | export type Spring = typeof(Spring.new()) 10 | export type SpringProperties = { 11 | damper: number?, 12 | speed: number?, 13 | start: any, 14 | target: any, 15 | force: any?, 16 | delay: number?, 17 | } 18 | 19 | function Spring.definition(props: SpringProperties) 20 | return { 21 | [1] = Symbols.Spring, 22 | [2] = props, 23 | } 24 | end 25 | 26 | function Spring.new(props: SpringProperties) 27 | local self = setmetatable(BaseAnimation.new(), Spring) :: Spring 28 | 29 | self.props = props 30 | self.player = nil 31 | 32 | return self 33 | end 34 | 35 | function Spring:Play(from: any?, immediate: boolean?) 36 | if self.playing then 37 | self:Stop() 38 | end 39 | 40 | local baseFromValue = self.props.start or from :: any 41 | local baseToValue = self.props.target :: any 42 | local force = self.props.force :: any 43 | 44 | assert(baseFromValue, "No start value provided") 45 | assert(baseToValue, "No target value provided") 46 | 47 | if baseFromValue == baseToValue and not force then 48 | return Promise.resolve() 49 | end 50 | 51 | local newSpring = SpringValue.new(baseFromValue, self.props.speed, self.props.damper) 52 | newSpring:SetImmediate(immediate) 53 | newSpring:SetGoal(baseToValue) 54 | newSpring:SetDelay(self.props.delay) 55 | 56 | local oldVelocity = self._oldSpring and self._oldSpring:GetVelocity() 57 | if oldVelocity then 58 | newSpring:Impulse(oldVelocity) 59 | end 60 | 61 | if force then 62 | newSpring:Impulse(force) 63 | end 64 | 65 | if self._oldSpring then 66 | self._oldSpring:Destroy() 67 | end 68 | 69 | local animation = newSpring:Run(function() 70 | if self.listener then 71 | self.listener(newSpring:GetValue()) 72 | end 73 | end) 74 | 75 | self.playing = true 76 | self.player = animation 77 | self._oldSpring = newSpring 78 | 79 | return animation 80 | end 81 | 82 | function Spring:Stop() 83 | if not self.playing then 84 | return 85 | end 86 | 87 | if self.player then 88 | self.player:cancel() 89 | self.player = nil 90 | end 91 | 92 | self.playing = false 93 | end 94 | 95 | return Spring 96 | -------------------------------------------------------------------------------- /src/Utility/LinearValue.lua: -------------------------------------------------------------------------------- 1 | local LinearValue = {} 2 | 3 | export type LinearValueType = 4 | number 5 | | UDim2 6 | | UDim 7 | | Vector2 8 | | Vector3 9 | | Color3 10 | | ColorSequenceKeypoint 11 | | NumberSequenceKeypoint 12 | | Rect 13 | | NumberRange 14 | | PhysicalProperties 15 | | BrickColor 16 | | CFrame 17 | | Ray 18 | | Region3 19 | | Region3int16 20 | 21 | function LinearValue.fromValue(value: any) 22 | local typeString = typeof(value) 23 | 24 | if typeString == "number" then 25 | return LinearValue.new(nil, value) 26 | elseif typeString == "UDim2" then 27 | return LinearValue.new(UDim2.new, value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset) 28 | elseif typeString == "UDim" then 29 | return LinearValue.new(UDim.new, value.Scale, value.Offset) 30 | elseif typeString == "Vector2" then 31 | return LinearValue.new(Vector2.new, value.X, value.Y) 32 | elseif typeString == "Vector3" then 33 | return LinearValue.new(Vector3.new, value.X, value.Y, value.Z) 34 | elseif typeString == "Color3" then 35 | return LinearValue.new(Color3.new, value.R, value.G, value.B) 36 | elseif typeString == "ColorSequenceKeypoint" then 37 | return LinearValue.new(ColorSequenceKeypoint.new, value.Time, value.Value) 38 | elseif typeString == "NumberSequenceKeypoint" then 39 | return LinearValue.new(NumberSequenceKeypoint.new, value.Time, value.Value, value.Envelope) 40 | elseif typeString == "NumberRange" then 41 | return LinearValue.new(NumberRange.new, value.Min, value.Max) 42 | elseif typeString == "PhysicalProperties" then 43 | return LinearValue.new(PhysicalProperties.new, value.Density, value.Friction, value.Elasticity) 44 | elseif typeString == "BrickColor" then 45 | return LinearValue.new(Color3.new, value.Color.R, value.Color.G, value.Color.B) 46 | elseif typeString == "CFrame" then 47 | local x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22 = value:components() 48 | return LinearValue.new(CFrame.new, x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22) 49 | end 50 | 51 | assert(false, "Unsupported type: " .. typeString) 52 | end 53 | 54 | function LinearValue.new(constructor, ...) 55 | return table.freeze({ 56 | _ccstr = constructor, 57 | _value = { ... }, 58 | 59 | ToValue = LinearValue.ToValue, 60 | Lerp = LinearValue.Lerp, 61 | }) 62 | end 63 | 64 | function LinearValue:ToValue() 65 | if self._ccstr then 66 | return self._ccstr(unpack(self._value)) 67 | else 68 | return unpack(self._value) 69 | end 70 | end 71 | 72 | function LinearValue:Lerp(other, alpha) 73 | local newValues = {} 74 | 75 | for i = 1, #self._value do 76 | newValues[i] = self._value[i] + (other._value[i] - self._value[i]) * alpha 77 | end 78 | 79 | return LinearValue.new(self._ccstr, unpack(newValues)) 80 | end 81 | 82 | return LinearValue 83 | -------------------------------------------------------------------------------- /src/Hooks/useGroupAnimation.lua: -------------------------------------------------------------------------------- 1 | local React = require(script.Parent.Parent.React) 2 | local useBinding = React.useBinding 3 | local useMemo = React.useMemo 4 | 5 | local GroupAnimationController = {} 6 | GroupAnimationController.__index = GroupAnimationController 7 | 8 | type StateSetters = { 9 | [string]: (any) -> nil, 10 | } 11 | 12 | export type Animation = { 13 | Play: (DefaultProperties, boolean?) -> Animation, 14 | Stop: () -> nil, 15 | } 16 | 17 | export type GroupAnimation = { 18 | [string]: Animation, 19 | } 20 | 21 | export type DefaultProperties = { 22 | [string]: any, 23 | } 24 | 25 | function GroupAnimationController.new(props: GroupAnimation, default: DefaultProperties, setters: StateSetters) 26 | local self = setmetatable({}, GroupAnimationController) 27 | 28 | self.currentState = "Default" 29 | self.animations = props 30 | 31 | self.state = default 32 | self.setters = setters 33 | 34 | for state, animation in props do 35 | animation:SetListener(function(name, value) 36 | if self.currentState ~= state then 37 | return 38 | end 39 | 40 | self.state[name] = value 41 | self.setters[name](value) 42 | end) 43 | end 44 | 45 | return self 46 | end 47 | 48 | function GroupAnimationController:Play(newState: string, immediate: boolean?) 49 | local animation = self.animations[newState] 50 | assert(animation, `No animation found for state {newState}`) 51 | 52 | self.currentState = newState 53 | 54 | if self.currentAnimation then 55 | self.currentAnimation:Stop() 56 | end 57 | 58 | self.currentAnimation = animation 59 | animation:Play(self.state, immediate) 60 | end 61 | 62 | function GroupAnimationController:Stop() 63 | if self.currentAnimation then 64 | self.currentAnimation:Stop() 65 | self.currentAnimation = nil 66 | end 67 | end 68 | 69 | function GroupAnimationController:UpdateSetters(setters: StateSetters) 70 | self.setters = setters 71 | end 72 | 73 | local function getStateContainer(defaults: DefaultProperties) 74 | local setters = {} 75 | local values = {} 76 | 77 | for name, value in defaults do 78 | local binding, updateBinding = useBinding(value) 79 | 80 | setters[name] = updateBinding 81 | values[name] = binding 82 | end 83 | 84 | return setters, values 85 | end 86 | 87 | local function useGroupAnimation(props: GroupAnimation, default: DefaultProperties) 88 | local defaults = useMemo(function() 89 | return default 90 | end, {}) 91 | 92 | local setters, values = getStateContainer(defaults) 93 | local controller = useMemo(function() 94 | local newController = GroupAnimationController.new(props, defaults, setters) 95 | 96 | return { 97 | updateSetters = function(newSetters: StateSetters) 98 | newController:UpdateSetters(newSetters) 99 | end, 100 | 101 | play = function(newState: string, immediate: boolean?) 102 | assert(typeof(newState) == "string", "useGroupAnimation expects a string 'state'") 103 | newController:Play(newState, immediate) 104 | end, 105 | 106 | stop = function() 107 | newController:Stop() 108 | end, 109 | } 110 | end, {}) 111 | 112 | controller.updateSetters(setters) 113 | 114 | return values, controller.play, controller.stop 115 | end 116 | 117 | return useGroupAnimation 118 | -------------------------------------------------------------------------------- /src/Hooks/useSequenceAnimation.lua: -------------------------------------------------------------------------------- 1 | local Promise = require(script.Parent.Parent.Promise) 2 | local Animations = require(script.Parent.Parent.Animations) 3 | 4 | local React = require(script.Parent.Parent.React) 5 | local useMemo = React.useMemo 6 | 7 | export type SequenceProps = { { timestamp: number } | any } 8 | 9 | local Sequence = {} 10 | Sequence.__index = Sequence 11 | 12 | function Sequence.new(props: SequenceProps) 13 | local self = setmetatable({}, Sequence) 14 | local animations = {} 15 | 16 | for i, animation in props do 17 | animations[i] = { 18 | timestamp = animation.timestamp, 19 | } 20 | 21 | for name, animatable in animation do 22 | if name == "timestamp" then 23 | continue 24 | end 25 | 26 | animations[i][name] = Animations.fromDefinition(animatable) 27 | end 28 | end 29 | 30 | self.playing = false 31 | self.listener = nil 32 | self.animation = animations 33 | 34 | table.sort(animations, function(a, b) 35 | return a.timestamp < b.timestamp 36 | end) 37 | 38 | local lastTimestamp 39 | 40 | for _, animation in animations do 41 | local timestamp = animation.timestamp 42 | if lastTimestamp == timestamp then 43 | error("Duplicate timestamp found in sequence") 44 | end 45 | 46 | lastTimestamp = timestamp 47 | 48 | for name, animatable in animation do 49 | if name == "timestamp" then 50 | continue 51 | end 52 | 53 | animatable:SetListener(function(value) 54 | if self.listener then 55 | self.listener(name, value) 56 | end 57 | end) 58 | end 59 | end 60 | 61 | return self 62 | end 63 | 64 | function Sequence:SetListener(listener: (string, any) -> ()) 65 | self.listener = listener 66 | end 67 | 68 | function Sequence:Play(fromProps: SequenceProps, immediate: boolean?) 69 | if self.playing then 70 | self:Stop() 71 | end 72 | 73 | local animation = Promise.new(function(resolve, _, onCancel) 74 | local promises = {} 75 | local playing = {} 76 | 77 | for _, sequenced in self.animation do 78 | local animationPromise = Promise.delay(if immediate then 0 else sequenced.timestamp):andThen(function() 79 | local allAnimatables = {} 80 | 81 | for name, animatable in sequenced do 82 | if name == "timestamp" then 83 | continue 84 | end 85 | 86 | if playing[name] then 87 | playing[name]:cancel() 88 | end 89 | 90 | local promise = animatable:Play(fromProps[name], immediate) 91 | playing[name] = promise 92 | 93 | table.insert(allAnimatables, promise) 94 | end 95 | 96 | return Promise.all(allAnimatables) 97 | end) 98 | 99 | table.insert(promises, animationPromise) 100 | end 101 | 102 | local awaiter = Promise.all(promises):andThen(resolve) 103 | 104 | onCancel(function() 105 | for _, promise in promises do 106 | promise:cancel() 107 | end 108 | 109 | awaiter:cancel() 110 | end) 111 | end) 112 | 113 | self.playing = true 114 | self.player = animation 115 | 116 | return animation 117 | end 118 | 119 | function Sequence:Stop() 120 | if not self.playing then 121 | return 122 | end 123 | 124 | if self.player then 125 | self.player:cancel() 126 | self.player = nil 127 | end 128 | 129 | self.playing = false 130 | end 131 | 132 | local function useSequenceAnimation(props: SequenceProps) 133 | return useMemo(function() 134 | return Sequence.new(props) 135 | end, {}) 136 | end 137 | 138 | return useSequenceAnimation 139 | -------------------------------------------------------------------------------- /src/Utility/SpringValue.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | 3 | local LinearValue = require(script.Parent.LinearValue) 4 | local Promise = require(script.Parent.Parent.Promise) 5 | 6 | local SpringValue = {} 7 | local SpringValues = {} 8 | SpringValue.__index = SpringValue 9 | 10 | local EPSILON = 1e-2 11 | 12 | function SpringValue.new(initial: LinearValue.LinearValueType, speed: number?, damper: number?) 13 | local target = LinearValue.fromValue(initial) 14 | local velocity = {} 15 | 16 | for i = 1, #target._value do 17 | velocity[i] = 0 18 | end 19 | 20 | return setmetatable({ 21 | _current = target, 22 | _goal = target, 23 | _velocities = velocity, 24 | _speed = speed or 1, 25 | _damper = damper or 1, 26 | _immediate = false, 27 | _updater = nil, 28 | _delay = nil :: number?, 29 | }, SpringValue) 30 | end 31 | 32 | function SpringValue:Destroy() 33 | SpringValues[self] = nil 34 | setmetatable(self, nil) 35 | end 36 | 37 | function SpringValue:Impulse(impulse: LinearValue.LinearValueType) 38 | local impulseValues = LinearValue.fromValue(impulse)._value 39 | for i = 1, #impulseValues do 40 | self._velocities[i] = (self._velocities[i] or 0) + impulseValues[i] 41 | end 42 | end 43 | 44 | function SpringValue:GetVelocity() 45 | return LinearValue.new(self._current._ccstr, unpack(self._velocities)):ToValue() 46 | end 47 | 48 | function SpringValue:SetGoal(goal: LinearValue.LinearValueType) 49 | self._goal = LinearValue.fromValue(goal) 50 | end 51 | 52 | function SpringValue:SetSpeed(speed: number) 53 | self._speed = speed 54 | end 55 | 56 | function SpringValue:SetDamper(damper: number) 57 | self._damper = damper 58 | end 59 | 60 | function SpringValue:SetImmediate(immediate: boolean) 61 | self._immediate = immediate 62 | end 63 | 64 | function SpringValue:SetDelay(delay: number?) 65 | if delay then 66 | assert(delay >= 0, "Delay must be a non-negative number") 67 | end 68 | 69 | self._delay = delay 70 | end 71 | 72 | function SpringValue:SetUpdater(updater: (any) -> ()) 73 | self._updater = updater 74 | 75 | if self:Playing() and updater then 76 | updater(self:GetValue()) 77 | end 78 | end 79 | 80 | function SpringValue:GetGoal() 81 | return self._goal:ToValue() 82 | end 83 | 84 | function SpringValue:SetValue(value: LinearValue.LinearValueType) 85 | self._current = LinearValue.fromValue(value) 86 | end 87 | 88 | function SpringValue:GetValue() 89 | return self._current:ToValue() 90 | end 91 | 92 | function SpringValue:Update(dt: number) 93 | local currentValues = self._current._value 94 | local goalValues = self._goal._value 95 | local velocities = self._velocities 96 | 97 | local newValues = {} 98 | local updated = false 99 | 100 | for i = 1, #currentValues do 101 | local goalValue = goalValues[i] 102 | local baseValue, baseVelocity = currentValues[i], velocities[i] or 0 103 | local position, newVelocity = self:getPositionVelocity(dt, baseValue, baseVelocity, goalValue) 104 | 105 | newValues[i] = position 106 | velocities[i] = newVelocity 107 | 108 | if math.abs(position - goalValue) > EPSILON or math.abs(newVelocity) > EPSILON then 109 | updated = true 110 | end 111 | end 112 | 113 | self._current = LinearValue.new(self._current._ccstr, unpack(newValues)) 114 | 115 | return updated 116 | end 117 | 118 | function SpringValue:Playing() 119 | return SpringValues[self] ~= nil 120 | end 121 | 122 | function SpringValue:Stop() 123 | local value = SpringValues[self] 124 | if value then 125 | SpringValues[self] = nil 126 | value() 127 | end 128 | end 129 | 130 | function SpringValue:Run(update: () -> ()?) 131 | if update then 132 | self._updater = update 133 | end 134 | 135 | if self._immediate then 136 | self._current = self._goal 137 | 138 | if self._updater then 139 | self._updater(self:GetValue()) 140 | end 141 | 142 | return Promise.resolve() 143 | end 144 | 145 | return Promise.new(function(resolve, _, onCancel) 146 | local cancelled = false 147 | onCancel(function() 148 | cancelled = true 149 | self:Stop() 150 | end) 151 | 152 | if self._delay then 153 | task.wait(self._delay) 154 | 155 | if cancelled then 156 | return 157 | end 158 | end 159 | 160 | if update then 161 | update(self:GetValue()) 162 | end 163 | 164 | SpringValues[self] = resolve 165 | end) 166 | end 167 | 168 | -- credit to @Quenty 169 | -- https://github.com/Quenty/NevermoreEngine/blob/main/src/spring/src/Shared/Spring.lua 170 | function SpringValue:getPositionVelocity(dt: number, current: number, velocity: number, target: number) 171 | local p0 = current 172 | local v0 = velocity 173 | local p1 = target 174 | local d = self._damper 175 | local s = self._speed 176 | 177 | local t = s * dt 178 | local d2 = d * d 179 | 180 | local h, si, co 181 | if d2 < 1 then 182 | h = math.sqrt(1 - d2) 183 | local ep = math.exp(-d * t) / h 184 | co, si = ep * math.cos(h * t), ep * math.sin(h * t) 185 | elseif d2 == 1 then 186 | h = 1 187 | local ep = math.exp(-d * t) / h 188 | co, si = ep, ep * t 189 | else 190 | h = math.sqrt(d2 - 1) 191 | local u = math.exp((-d + h) * t) / (2 * h) 192 | local v = math.exp((-d - h) * t) / (2 * h) 193 | co, si = u + v, u - v 194 | end 195 | 196 | local a0 = h * co + d * si 197 | local a1 = 1 - (h * co + d * si) 198 | local a2 = si / s 199 | 200 | local b0 = -s * si 201 | local b1 = s * si 202 | local b2 = h * co - d * si 203 | 204 | return a0 * p0 + a1 * p1 + a2 * v0, b0 * p0 + b1 * p1 + b2 * v0 205 | end 206 | 207 | RunService:UnbindFromRenderStep("UPDATE_SPRING_VALUES") 208 | RunService:BindToRenderStep("UPDATE_SPRING_VALUES", Enum.RenderPriority.First.Value, function(dt: number) 209 | for spring, resolve in pairs(SpringValues) do 210 | local didUpdate = spring:Update(dt) 211 | local value = spring:GetValue() 212 | 213 | if spring._updater then 214 | spring._updater(value) 215 | end 216 | 217 | if not didUpdate then 218 | SpringValues[spring] = nil 219 | resolve() 220 | end 221 | end 222 | end) 223 | 224 | return SpringValue 225 | -------------------------------------------------------------------------------- /src/Animations/Types/Tween.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | local TweenService = game:GetService("TweenService") 3 | 4 | local BaseAnimation = require(script.Parent.Parent.Base) 5 | local Promise = require(script.Parent.Parent.Parent.Promise) 6 | local LinearValue = require(script.Parent.Parent.Parent.Utility.LinearValue) 7 | local Symbols = require(script.Parent.Parent.Symbols) 8 | 9 | local Tween = {} 10 | Tween.__index = Tween 11 | 12 | type Callback = (T) -> () 13 | 14 | export type Tween = typeof(Tween.new()) 15 | export type TweenProperties = { 16 | info: TweenInfo, 17 | startImmediate: T?, 18 | start: T, 19 | target: T, 20 | delay: number?, 21 | } 22 | 23 | local callbacks = {} 24 | local pooledUpdateConnection: RBXScriptConnection? = nil 25 | 26 | local function pooledUpdate(callback: Callback): () -> () 27 | callbacks[callback] = true 28 | 29 | if not pooledUpdateConnection then 30 | pooledUpdateConnection = RunService.RenderStepped:Connect(function(dt) 31 | local ran = false 32 | 33 | for nextCallback in callbacks do 34 | ran = true 35 | nextCallback(dt) 36 | end 37 | 38 | if not ran and pooledUpdateConnection then 39 | pooledUpdateConnection:Disconnect() 40 | pooledUpdateConnection = nil 41 | end 42 | end) 43 | end 44 | 45 | return function() 46 | callbacks[callback] = nil 47 | if next(callbacks) == nil and pooledUpdateConnection then 48 | pooledUpdateConnection:Disconnect() 49 | pooledUpdateConnection = nil 50 | end 51 | end 52 | end 53 | 54 | local function playTween(tweenInfo, callback: (number) -> nil, completed: () -> nil) 55 | local numberValue = Instance.new("NumberValue") 56 | numberValue.Value = 0 57 | numberValue:GetPropertyChangedSignal("Value"):Connect(function() 58 | callback(numberValue.Value) 59 | end) 60 | 61 | local tween = TweenService:Create(numberValue, tweenInfo, { 62 | Value = 1, 63 | }) 64 | 65 | tween.Completed:Once(function() 66 | numberValue:Destroy() 67 | completed() 68 | end) 69 | 70 | return function() 71 | callback(0) 72 | tween:Play() 73 | end, function() 74 | numberValue:Destroy() 75 | tween:Cancel() 76 | end 77 | end 78 | 79 | local function playTween2(tweenInfo: TweenInfo, callback: Callback, completed: Callback) 80 | local disconnect 81 | 82 | local repeats = 0 83 | local elapsed = 0 84 | 85 | local tweenTime = tweenInfo.Time 86 | local tweenDelay = tweenInfo.DelayTime 87 | 88 | local tweenRepeatCount = tweenInfo.RepeatCount 89 | local tweenReverses = tweenInfo.Reverses 90 | 91 | local tweenEasing = tweenInfo.EasingStyle 92 | local tweenDirection = tweenInfo.EasingDirection 93 | 94 | assert(tweenReverses == false, "Tween reverses is not supported") 95 | 96 | local function stop() 97 | if disconnect then 98 | disconnect() 99 | disconnect = nil 100 | end 101 | end 102 | 103 | local function play() 104 | elapsed = 0 105 | repeats = 0 106 | 107 | if tweenDelay and tweenDelay > 0 then 108 | elapsed = -tweenDelay 109 | end 110 | 111 | if not disconnect then 112 | disconnect = pooledUpdate(function(dt) 113 | elapsed += dt 114 | 115 | local alpha = math.clamp(elapsed / tweenTime, 0, 1) 116 | local value = TweenService:GetValue(alpha, tweenEasing, tweenDirection) 117 | 118 | callback(value) 119 | 120 | if alpha >= 1 then 121 | if tweenRepeatCount ~= 0 and repeats < tweenRepeatCount then 122 | repeats += 1 123 | elapsed = 0 124 | return 125 | end 126 | 127 | stop() 128 | completed() 129 | end 130 | end) 131 | end 132 | end 133 | 134 | return play, stop 135 | end 136 | 137 | function Tween.definition(props: TweenProperties) 138 | return { 139 | [1] = Symbols.Tween, 140 | [2] = props, 141 | } 142 | end 143 | 144 | function Tween.new(props: TweenProperties) 145 | local self = setmetatable(BaseAnimation.new(), Tween) :: Tween 146 | 147 | self.props = props 148 | self.player = nil 149 | 150 | return self 151 | end 152 | 153 | function Tween:Play(from: any?, immediate: boolean?) 154 | if self.playing then 155 | self:Stop() 156 | end 157 | 158 | local tweenInfo = self.props.info :: TweenInfo 159 | local baseFromValue = self.props.startImmediate or self.props.start or from :: any 160 | local baseToValue = self.props.target :: any 161 | 162 | -- start immediately will start the tween but not update the listener 163 | local startImmediately = self.props.startImmediate ~= nil 164 | local delayTime = self.props.delay :: number 165 | 166 | if not delayTime then 167 | assert(startImmediately == false, "Cannot start immediately without a delay") 168 | else 169 | if startImmediately then 170 | assert(delayTime > 0, "DelayTime must be greater than zero") 171 | end 172 | end 173 | 174 | assert(baseFromValue, "No start value provided") 175 | assert(baseToValue, "No target value provided") 176 | 177 | assert(tweenInfo, "No tween info provided") 178 | assert(tweenInfo.RepeatCount == 0, "RepeatCount must be 0") 179 | assert(tweenInfo.Reverses == false, "Reverses must be false") 180 | assert(tweenInfo.DelayTime == 0, "DelayTime must be 0") 181 | 182 | if baseFromValue == baseToValue then 183 | return Promise.resolve() 184 | end 185 | 186 | local fromValue = LinearValue.fromValue(baseFromValue) 187 | local toValue = LinearValue.fromValue(baseToValue) 188 | 189 | if immediate then 190 | self.listener(baseToValue) 191 | 192 | self.playing = false 193 | self.player = nil 194 | 195 | return Promise.resolve() 196 | end 197 | 198 | local animation = Promise.new(function(resolve, _, onCancel) 199 | local play, cancel = playTween2(tweenInfo, function(value) 200 | local newValue = fromValue:Lerp(toValue, value):ToValue() 201 | self.listener(newValue) 202 | end, function() 203 | self.playing = false 204 | self.player = nil 205 | resolve() 206 | end) 207 | 208 | onCancel(cancel) 209 | 210 | if not delayTime then 211 | play() 212 | else 213 | if startImmediately then 214 | self.listener(baseFromValue) 215 | end 216 | 217 | task.wait(delayTime) 218 | play() 219 | end 220 | end) 221 | 222 | self.playing = true 223 | self.player = animation 224 | 225 | return animation 226 | end 227 | 228 | function Tween:Stop() 229 | if not self.playing then 230 | return 231 | end 232 | 233 | if self.player then 234 | self.player:cancel() 235 | self.player = nil 236 | end 237 | 238 | self.playing = false 239 | end 240 | 241 | return Tween 242 | -------------------------------------------------------------------------------- /test/Test.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local Packages = ReplicatedStorage:WaitForChild("Packages") 3 | 4 | local React = require(Packages.React) 5 | local useEffect = React.useEffect 6 | local createElement = React.createElement 7 | 8 | local ReactAnimation = require(Packages.ReactAnimation) 9 | local useGroupAnimation = ReactAnimation.useGroupAnimation 10 | local useSequenceAnimation = ReactAnimation.useSequenceAnimation 11 | local useSpring = ReactAnimation.useSpring 12 | local useTween = ReactAnimation.useTween 13 | local useBindingEffect = ReactAnimation.useBindings 14 | local useAnimation = ReactAnimation.useAnimation 15 | local Spring = ReactAnimation.Spring 16 | local Tween = ReactAnimation.Tween 17 | 18 | -- @helpers 19 | 20 | local function createFrame(props, children) 21 | children = children or {} 22 | 23 | props.BackgroundColor3 = props.BackgroundColor3 or Color3.fromRGB(61, 61, 61) 24 | 25 | props.Size = props.Size or UDim2.fromScale(1, 1) 26 | props.SizeConstraint = Enum.SizeConstraint.RelativeXX 27 | props.Text = props.Name 28 | 29 | props.Font = props.Font or Enum.Font.GothamBlack 30 | props.TextSize = props.TextSize or 20 31 | props.TextColor3 = props.TextColor3 or Color3.fromRGB(255, 255, 255) 32 | 33 | local parentSize = props.ParentSize or UDim2.fromScale(0.1, 0.25) 34 | props.ParentSize = nil 35 | 36 | children.uiStroke = createElement("UIStroke", { 37 | Thickness = 2, 38 | Color = Color3.fromRGB(58, 58, 58), 39 | Transparency = 0, 40 | ApplyStrokeMode = Enum.ApplyStrokeMode.Border, 41 | }) 42 | 43 | children.uiCorner = createElement("UICorner", { 44 | CornerRadius = UDim.new(0, 8), 45 | }) 46 | 47 | return createElement("Frame", { 48 | Size = parentSize, 49 | AnchorPoint = Vector2.new(0.5, 0), 50 | BackgroundTransparency = 1, 51 | }, { 52 | content = createElement("TextLabel", props, children), 53 | }) 54 | end 55 | 56 | -- @tests 57 | 58 | -- @TestAnimation 59 | 60 | local function TestAnimation() 61 | local sequence, play, stop = useGroupAnimation({ 62 | moveRight = useAnimation({ 63 | textSize = Tween({ 64 | info = TweenInfo.new(1, Enum.EasingStyle.Back), 65 | target = 25, 66 | }), 67 | position = Spring({ 68 | target = UDim2.fromScale(0.8, 0), 69 | speed = 5, 70 | damper = 0.7, 71 | }), 72 | }), 73 | 74 | moveLeft = useAnimation({ 75 | position = Spring({ 76 | target = UDim2.fromScale(0.3, 0), 77 | speed = 5, 78 | damper = 0.7, 79 | }), 80 | textSize = Tween({ 81 | info = TweenInfo.new(1, Enum.EasingStyle.Back), 82 | target = 10, 83 | }), 84 | }), 85 | }, { 86 | textSize = 10, 87 | size = UDim2.fromOffset(200, 200), 88 | position = UDim2.fromScale(0.5, 0), 89 | }) 90 | 91 | useEffect(function() 92 | local running = true 93 | local thread = task.spawn(function() 94 | while running do 95 | play("moveLeft") 96 | task.wait(2) 97 | play("moveRight") 98 | task.wait(2) 99 | end 100 | end) 101 | 102 | return function() 103 | running = false 104 | task.cancel(thread) 105 | stop() 106 | end 107 | end, {}) 108 | 109 | return createFrame({ 110 | Name = "Animation Group", 111 | Position = sequence.position, 112 | Size = sequence.size, 113 | TextSize = sequence.textSize, 114 | ParentSize = UDim2.fromScale(1, 0.25), 115 | }) 116 | end 117 | 118 | -- @TestSequence 119 | local function TestSequence() 120 | local sequence, play, stop = useGroupAnimation({ 121 | yo = useSequenceAnimation({ 122 | { 123 | timestamp = 0, 124 | size = Tween({ 125 | target = UDim2.fromOffset(200, 200), 126 | info = TweenInfo.new(1, Enum.EasingStyle.Cubic, Enum.EasingDirection.InOut), 127 | }), 128 | }, 129 | { 130 | timestamp = 1, 131 | position = Tween({ 132 | target = UDim2.fromScale(0.2, 0), 133 | info = TweenInfo.new(1, Enum.EasingStyle.Cubic, Enum.EasingDirection.InOut), 134 | }), 135 | 136 | size = Tween({ 137 | target = UDim2.fromOffset(400, 400), 138 | info = TweenInfo.new(1, Enum.EasingStyle.Cubic, Enum.EasingDirection.InOut), 139 | }), 140 | }, 141 | { 142 | timestamp = 2, 143 | size = Tween({ 144 | target = UDim2.fromOffset(100, 100), 145 | info = TweenInfo.new(0.5, Enum.EasingStyle.Cubic, Enum.EasingDirection.InOut), 146 | }), 147 | position = Tween({ 148 | target = UDim2.fromScale(0.8, 0), 149 | info = TweenInfo.new(0.5, Enum.EasingStyle.Cubic, Enum.EasingDirection.InOut), 150 | }), 151 | }, 152 | { 153 | timestamp = 2.5, 154 | size = Tween({ 155 | target = UDim2.fromOffset(200, 200), 156 | info = TweenInfo.new(0.5, Enum.EasingStyle.Cubic, Enum.EasingDirection.InOut), 157 | }), 158 | position = Tween({ 159 | target = UDim2.fromScale(0.5, 0), 160 | info = TweenInfo.new(0.5, Enum.EasingStyle.Cubic, Enum.EasingDirection.InOut), 161 | }), 162 | }, 163 | }), 164 | }, { 165 | position = UDim2.fromScale(0.5, 0), 166 | size = UDim2.fromOffset(200, 200), 167 | }) 168 | 169 | useEffect(function() 170 | local running = true 171 | local thread = task.spawn(function() 172 | while running do 173 | play("yo") 174 | task.wait(3) 175 | end 176 | end) 177 | 178 | return function() 179 | running = false 180 | task.cancel(thread) 181 | stop() 182 | end 183 | end, {}) 184 | 185 | return createFrame({ 186 | Name = "Sequence", 187 | Position = sequence.position, 188 | Size = sequence.size, 189 | ParentSize = UDim2.fromScale(1, 0.25), 190 | }) 191 | end 192 | 193 | -- -- @TestTween 194 | local function TestTween() 195 | local value, update = useTween({ 196 | info = TweenInfo.new(1), 197 | start = UDim2.fromScale(0, 0), 198 | }) 199 | 200 | local value2, update2 = useTween({ 201 | info = TweenInfo.new(1), 202 | start = Color3.new(), 203 | }) 204 | 205 | useBindingEffect(function(v) 206 | if v.X.Scale == 0 then 207 | update({ target = UDim2.fromScale(1, 0) }) 208 | elseif (v :: UDim2).X.Scale == 1 then 209 | update({ target = UDim2.fromScale(0, 0) }) 210 | end 211 | end, { value }, {}) 212 | 213 | useBindingEffect(function(v2) 214 | if v2.R == 0 then 215 | update2({ start = Color3.new(0, 1, 9), target = Color3.new(1, 0, 0) }) 216 | elseif v2.R == 1 then 217 | update2({ target = Color3.new(0, 1, 0) }) 218 | end 219 | end, { value2 }, {}) 220 | 221 | return createFrame({ 222 | Name = "Tween", 223 | Position = value, 224 | BackgroundColor3 = value2, 225 | }) 226 | end 227 | 228 | -- -- @TestSpring 229 | local function TestSpring() 230 | local value, update = useSpring({ 231 | start = UDim2.fromScale(0, 0), 232 | speed = 5, 233 | damper = 0.7, 234 | }) 235 | 236 | useEffect(function() 237 | local running = true 238 | local thread = task.spawn(function() 239 | while running do 240 | update({ target = UDim2.fromScale(1, 0) }) 241 | task.wait(3) 242 | 243 | if not running then 244 | break 245 | end 246 | 247 | update({ target = UDim2.fromScale(-1, 0) }) 248 | task.wait(3) 249 | end 250 | end) 251 | 252 | return function() 253 | running = false 254 | task.cancel(thread) 255 | end 256 | end, {}) 257 | 258 | return createFrame({ 259 | Name = "Spring", 260 | Position = value, 261 | }) 262 | end 263 | 264 | -- @entry 265 | local function Test() 266 | return createElement("Frame", { 267 | Size = UDim2.fromScale(1, 1), 268 | BackgroundTransparency = 1, 269 | }, { 270 | uiListLayout = createElement("UIListLayout", { 271 | HorizontalAlignment = Enum.HorizontalAlignment.Center, 272 | VerticalAlignment = Enum.VerticalAlignment.Center, 273 | }), 274 | 275 | tween = createElement(TestTween), 276 | spring = createElement(TestSpring), 277 | sequence = createElement(TestSequence), 278 | animation = createElement(TestAnimation), 279 | }) 280 | end 281 | 282 | return Test 283 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | Slither Icon 4 |
5 |
6 | React-Flow 7 |

8 | 9 |
10 | ⚡ A blazing fast animation library for React-Lua interfaces, providing stateful animations with unrestricted flexibility and performance. 🤌 11 |
12 | 13 |
14 |
15 | 16 | [![Version](https://img.shields.io/github/v/release/outofbears/react-flow.svg?style=flat-square)](https://github.com/outofbears/react-flow/releases) 17 | [![License](https://img.shields.io/github/license/outofbears/react-flow.svg?style=flat-square)](https://github.com/outofbears/react-flow/blob/main/LICENSE.md) 18 | [![Stars](https://img.shields.io/github/stars/outofbears/react-flow.svg?style=flat-square)](https://github.com/outofbears/react-flow/stargazers) 19 | [![Forks](https://img.shields.io/github/forks/outofbears/react-flow.svg?style=flat-square)](https://github.com/outofbears/react-flow/network/members) 20 | [![Watchers](https://img.shields.io/github/watchers/outofbears/react-flow.svg?style=flat-square)](https://github.com/outofbears/react-flow/watchers) 21 | [![Issues](https://img.shields.io/github/issues/outofbears/react-flow.svg?style=flat-square)](https://github.com/outofbears/react-flow/issues) 22 | [![Pull Requests](https://img.shields.io/github/issues-pr/outofbears/react-flow.svg?style=flat-square)](https://github.com/outofbears/react-flow/pulls) 23 | [![Last Commit](https://img.shields.io/github/last-commit/outofbears/react-flow.svg?style=flat-square)](https://github.com/outofbears/react-flow/commits/main) 24 | 25 | 26 |
27 | 28 | 29 | ## 📋 Table of Contents 30 | 31 | - [Features](#-features) 32 | - [Installation](#-installation) 33 | - [Using Wally](#using-wally-recommended) 34 | - [Manual Installation](#manual-installation) 35 | - [Hooks](#-hooks) 36 | - [useSpring](#usespring) 37 | - [useTween](#usetween) 38 | - [useGroupAnimation](#usegroupanimation) 39 | - [Supported Value Types](#-supported-value-types) 40 | - [Showcase](#-showcase) 41 | - [Contribution](#-contribution) 42 | - [License](#-license) 43 | 44 | ## ✨ Features 45 | 46 | - 🔄 **Stateful Animations** - Animations that automatically respond to your component's state changes, ensuring UI and state stay perfectly synchronized 47 | - ⛓️ **Chainable Animations** - Effortlessly build complex animation sequences that flow naturally from one to another 48 | - 🔀 **Interruptible Flows** - Gracefully handle user interactions by modifying animations mid-flight without jarring visual transitions 49 | - 🧩 **Composable System** - Create reusable animation components that can be combined in endless ways for consistent motion design 50 | - 🛡️ **Memory-Safe Design** - Built with React's lifecycle in mind to prevent memory leaks and ensure proper cleanup 51 | 52 | ## 📦 Installation 53 | 54 | ### Using Wally (Recommended) 55 | 56 | Add React-Flow to your `wally.toml` file: 57 | 58 | ```toml 59 | [dependencies] 60 | ReactFlow = "outofbears/react-flow@0.2.0" 61 | ``` 62 | 63 | Then install with: 64 | ```bash 65 | wally install 66 | ``` 67 | 68 | ### Manual Installation 69 | 70 | Simply clone the repository and include it in your project structure. 71 | 72 | ### Requiring the Module 73 | 74 | Once installed, require React-Flow in your code: 75 | 76 | ```lua 77 | -- For common Roblox setups: 78 | local ReactFlow = require(ReplicatedStorage.Packages.ReactFlow) 79 | ``` 80 | 81 | ## 🔧 Hooks 82 | 83 | ### `useSpring` 84 | 85 | Creates spring-based physics animations with React bindings. Springs provide natural, bouncy motion that reacts to changes dynamically. 86 | 87 | **Arguments:** 88 | - **config:** A configuration table with the following properties: 89 | - **start:** Initial value of the animation (required) 90 | - **target:** Target value to animate toward (optional) 91 | - **speed:** Spring stiffness - higher values create faster motion (default: 10) 92 | - **damper:** Damping ratio - higher values reduce bouncing (default: 1) 93 | 94 | **Returns:** 95 | A binding that updates as the animation progresses, and an update function to modify the animation. 96 | 97 | **Example:** 98 | ```lua 99 | local useSpring = ReactFlow.useSpring 100 | 101 | -- Inside your component: 102 | local position, updatePosition = useSpring({ 103 | start = UDim2.fromScale(0, 0), -- Initial Value (required) 104 | target = UDim2.fromScale(0.5, 0.5), -- Target value (optional) 105 | 106 | speed = 20, 107 | damper = 0.8, 108 | }) 109 | 110 | -- Later, update the spring with new parameters: 111 | updatePosition({ 112 | target = UDim2.fromScale(0.5, 0.5), 113 | 114 | speed = 15, 115 | damper = 0.7, 116 | }) 117 | 118 | -- Use in your component: 119 | return createElement("Frame", { 120 | Position = position, -- Use binding directly in property 121 | }) 122 | ``` 123 | 124 | --- 125 | 126 | ### `useTween` 127 | 128 | Creates tween-based animations that follow a specific timing curve. Ideal for animations that need precise timing or easing effects. 129 | 130 | **Arguments:** 131 | - **config:** A configuration table with the following properties: 132 | - **start:** Initial value of the animation (required) 133 | - **target:** Target value to animate toward (optional) 134 | - **info:** TweenInfo instance (required) 135 | 136 | **Returns:** 137 | A binding that updates as the animation progresses, and an update function to modify the animation. 138 | 139 | **Example:** 140 | ```lua 141 | local useTween = ReactFlow.useTween 142 | 143 | -- Inside your component: 144 | local transparency, updateTransparency = useTween({ 145 | start = 1, -- Initial value (required) 146 | target = 0, -- Target value (optional) 147 | 148 | -- TweenInfo - controls duration, easing style, and behavior 149 | info = TweenInfo.new( 150 | 0.5, 151 | Enum.EasingStyle.Quad, 152 | Enum.EasingDirection.Out, 153 | ) 154 | }) 155 | 156 | -- Later, update the tween: 157 | updateTransparency({ 158 | target = 0, -- New target value 159 | 160 | -- Optional: update tween configuration 161 | info = TweenInfo.new(0.3, Enum.EasingStyle.Quart, Enum.EasingDirection.InOut), 162 | }) 163 | 164 | -- Use in your component: 165 | return createElement("Frame", { 166 | BackgroundTransparency = transparency, 167 | }) 168 | ``` 169 | 170 | --- 171 | 172 | ### `useGroupAnimation` 173 | 174 | Creates a group of animations that are managed together as a single entity. With `useGroupAnimation`, you can define multiple animation states by combining the following animation primitives: `useAnimation`, `useSpringAnimation`, `useSequenceAnimation`, and `useTweenAnimation`. This allows you to define complex animation states and switch between them seamlessly at runtime, providing an elegant way to handle UI state transitions. 175 | 176 | **Arguments:** 177 | - **animations:** A table mapping state names (e.g., "active", "inactive") to their animation definitions. Each definition can mix multiple animation types. 178 | - **defaults:** A table providing the default initial values for each animation property. 179 | 180 | **Returns:** 181 | A table of bindings for each animation property, and a function (commonly named `playAnimation`) that accepts a state name to switch between the defined animation groups. 182 | 183 | **Example:** 184 | 185 | ```lua 186 | local useGroupAnimation = ReactFlow.useGroupAnimation 187 | local useSequenceAnimation = ReactFlow.useSequenceAnimation 188 | local useAnimation = ReactFlow.useAnimation 189 | 190 | local Spring = ReactFlow.Spring 191 | local Tween = ReactFlow.Tween 192 | 193 | -- Inside your component: 194 | local animations, playAnimation = useGroupAnimation({ 195 | enable = useSequenceAnimation({ 196 | { 197 | timestamp = 0, 198 | transparency = Tween({target = 0, info = TweenInfo.new(0.2)}), 199 | }, 200 | { 201 | timestamp = 0.2, 202 | position = Spring({target = UDim2.fromScale(0.5, 0.5), speed = 20}), 203 | }, 204 | }), 205 | disable = useAnimation({ 206 | transparency = Tween({target = 1, info = TweenInfo.new(0.1)}), 207 | position = Spring({target = UDim2.fromScale(0.5, 1), speed = 25}), 208 | }), 209 | }, { 210 | transparency = 1 211 | position = UDim2.fromScale(0.5, 1), 212 | }) 213 | 214 | -- Play the animation with the specificed name: 215 | if enabled then 216 | playAnimation("enable") 217 | else 218 | playAnimation( 219 | "disable", 220 | true -- Optional second argument to play animation immediately 221 | ) 222 | end 223 | 224 | -- Use the animation bindings in your component: 225 | return createElement("Frame", { 226 | Size = UDim2.new(0, 100, 0, 100), 227 | BackgroundTransparency = animations.transparency, 228 | Position = animations.position, 229 | }) 230 | ``` 231 | 232 | --- 233 | 234 | ### `DynamicList` 235 | 236 | `DynamicList` is a component that automatically tracks and manages the addition, removal, and updating of child elements based on changes to its `children` prop. It ensures that its internal state stays synchronized with the provided `children`, updating dynamically when the children list changes. 237 | 238 | Key behavior: 239 | 240 | - **Child management**: The component will add new children or update existing ones based on changes in the `children` prop. 241 | - **Removing children**: When a child element is removed, it must call its `destroy` handler to clean up. The `remove` handler will notify `DynamicList` that the child has been removed by the parent, triggering the necessary state updates. 242 | 243 | This makes it easy to create lists where child elements can be added, updated, or removed without requiring manual state management. 244 | 245 | **Arguments:** 246 | 247 | - **children**: A table containing the elements to be managed by the list. The elements are automatically synchronized with the list’s internal state. 248 | 249 | **Returns:** 250 | 251 | A `DynamicList` component that handles the automatic synchronization of its children, ensuring they stay in sync with the latest state. 252 | 253 | **Example:** 254 | 255 | ```lua 256 | local items, updateItems = useState({ item1 = "hello world!" }) 257 | 258 | useEffect(function() 259 | local thread = task.delay(5, function() 260 | updateItems(function(state) 261 | local newState = table.clone(state) 262 | newState.item1 = nil 263 | return newState 264 | end) 265 | end) 266 | 267 | return function() 268 | task.cancel(thread) 269 | end 270 | end, {}) 271 | 272 | return createElement(DynamicList, {}, { 273 | item1 = items.items1 and createElement("TextLabel", { 274 | Text = items.item1, 275 | }) 276 | }) 277 | ``` 278 | 279 | ## 📊 Supported Value Types 280 | 281 | React-Flow supports animating the following userdata and native types: 282 | 283 | ### Basic Types 284 | - `number` - Numeric values 285 | - `UDim2` - 2D positioning (scale and offset) 286 | - `UDim` - 1D positioning (scale and offset) 287 | - `Vector2` - 2D vectors 288 | - `Vector3` - 3D vectors 289 | - `Color3` - RGB color values 290 | 291 | ### Advanced Types 292 | - `CFrame` - Position and orientation 293 | - `ColorSequenceKeypoint` - Color gradient keypoints 294 | - `NumberSequenceKeypoint` - Number gradient keypoints 295 | - `BrickColor` - Legacy colors 296 | - `NumberRange` - Min/max ranges 297 | - `PhysicalProperties` - Physics simulation properties 298 | - `Ray` - Line segments 299 | - `Region3` - 3D spatial regions 300 | - `Region3int16` - Integer-based 3D regions 301 | 302 | ## 🎬 Showcase 303 | 304 |
305 | RoundControl 306 |

Round Control Interface

307 |
308 | 309 |
310 | TowerUpgrade 311 |

Tower Upgrade Interface

312 |
313 | 314 |
315 | NPCDialogue 316 |

NPC Dialogue

317 |
318 | 319 | 320 | ## 💖 Contribution 321 | 322 | React-Flow was developed by [@Nexure](https://github.com/OutOfBears) with the assistance of [@GreenDeno](https://github.com/GreenDeno) 323 | 324 | ## 📝 License 325 | 326 | This project is licensed under the MIT License - see the [`LICENSE`](LICENSE) file for details. 327 | --------------------------------------------------------------------------------