Add website package and assets
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// --- Vector math utilities ---
|
||||
|
||||
interface Vec2 {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function vec2(x: number, y: number): Vec2 {
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function add(a: Vec2, b: Vec2): Vec2 {
|
||||
return { x: a.x + b.x, y: a.y + b.y }
|
||||
}
|
||||
|
||||
function sub(a: Vec2, b: Vec2): Vec2 {
|
||||
return { x: a.x - b.x, y: a.y - b.y }
|
||||
}
|
||||
|
||||
function scale(v: Vec2, s: number): Vec2 {
|
||||
return { x: v.x * s, y: v.y * s }
|
||||
}
|
||||
|
||||
function length(v: Vec2): number {
|
||||
return Math.sqrt(v.x * v.x + v.y * v.y)
|
||||
}
|
||||
|
||||
function normalize(v: Vec2): Vec2 {
|
||||
const len = length(v)
|
||||
if (len === 0) return { x: 0, y: 0 }
|
||||
return { x: v.x / len, y: v.y / len }
|
||||
}
|
||||
|
||||
function lerpAngle(a: number, b: number, t: number): number {
|
||||
// Handle angle wrapping for smooth interpolation
|
||||
let diff = b - a
|
||||
while (diff > 180) diff -= 360
|
||||
while (diff < -180) diff += 360
|
||||
return a + diff * t
|
||||
}
|
||||
|
||||
// --- Cursor context ---
|
||||
|
||||
const CursorContext = createContext<Vec2 | null>(null)
|
||||
|
||||
export function CursorFieldProvider({ children }: { children: React.ReactNode }) {
|
||||
const [cursor, setCursor] = useState<Vec2 | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
setCursor({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
setCursor(null)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseleave', handleMouseLeave)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseleave', handleMouseLeave)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <CursorContext.Provider value={cursor}>{children}</CursorContext.Provider>
|
||||
}
|
||||
|
||||
// --- Physics simulation state ---
|
||||
|
||||
interface ButterflyState {
|
||||
position: Vec2 // Current offset from origin
|
||||
velocity: Vec2 // Current velocity
|
||||
heading: number // Current facing angle in degrees (0 = down, positive = clockwise)
|
||||
targetHeading: number // Target heading to smoothly rotate towards
|
||||
}
|
||||
|
||||
// Physics constants
|
||||
const WANDER_STRENGTH = 0.15 // How strongly butterflies wander
|
||||
const WANDER_RADIUS = 40 // Max wander distance from origin
|
||||
const RETURN_STRENGTH = 0.02 // How strongly butterflies return to origin
|
||||
const REPULSION_RADIUS = 150 // Cursor repulsion detection radius
|
||||
const REPULSION_STRENGTH = 2.5 // How strongly cursor repels
|
||||
const MAX_SPEED = 1.5 // Maximum velocity
|
||||
const FRICTION = 0.98 // Velocity damping per frame
|
||||
const HEADING_SMOOTHING = 0.08 // How quickly heading catches up to velocity direction
|
||||
|
||||
// --- Butterfly visual component ---
|
||||
|
||||
interface ButterflyVisualProps {
|
||||
size?: number
|
||||
color?: string
|
||||
delay?: number
|
||||
duration?: number
|
||||
direction?: 'left' | 'right'
|
||||
heading?: number
|
||||
}
|
||||
|
||||
function ButterflyVisual({
|
||||
size = 40,
|
||||
color = '#e8a87c',
|
||||
delay = 0,
|
||||
duration = 0.5,
|
||||
direction = 'right',
|
||||
heading = 0,
|
||||
}: ButterflyVisualProps) {
|
||||
const wingStyle = {
|
||||
animationDelay: `${delay}s`,
|
||||
animationDuration: `${duration}s`,
|
||||
}
|
||||
|
||||
const scaleX = direction === 'left' ? -1 : 1
|
||||
|
||||
// Base butterfly has rotate(30deg), so it's already looking 30° down from horizontal.
|
||||
// To keep it in the bottom half (never looking up), rotateZ range is -30° to +90°
|
||||
// -30° = horizontal, 0° = 30° down (base), +90° = 120° down (nearly straight down)
|
||||
const clampedHeading = Math.max(-30, Math.min(90, heading))
|
||||
|
||||
return (
|
||||
<div
|
||||
className="animate-flutter-body"
|
||||
style={{
|
||||
animationDelay: `${delay}s`,
|
||||
animationDuration: `${duration}s`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 50 40"
|
||||
width={size}
|
||||
height={size * 0.8}
|
||||
style={{
|
||||
filter: 'url(#painterly)',
|
||||
transform: `scaleX(${scaleX}) rotateY(25deg) rotate(30deg) rotateZ(${clampedHeading}deg)`,
|
||||
transition: 'transform 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<filter id="painterly" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.03" numOctaves="2" result="noise" />
|
||||
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2" xChannelSelector="R" yChannelSelector="G" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Far side wings (darker, behind, slightly rotated) */}
|
||||
<g
|
||||
className="animate-flutter-left origin-[38px_20px]"
|
||||
style={{ ...wingStyle, animationDelay: `${delay + 0.08}s` }}
|
||||
transform="translate(6, 0) rotate(8, 38, 20)"
|
||||
>
|
||||
<path d="M38 20 Q20 2 8 10 Q0 18 10 28 Q25 32 38 22" fill={darken(color, 50)} />
|
||||
<path d="M38 22 Q25 30 18 38 Q28 42 36 34 Q40 28 38 24" fill={darken(color, 50)} />
|
||||
</g>
|
||||
|
||||
{/* Near side wings (main color, front) */}
|
||||
<g className="animate-flutter-left origin-[38px_20px]" style={wingStyle}>
|
||||
<path d="M38 20 Q20 2 8 10 Q0 18 10 28 Q25 32 38 22" fill={color} />
|
||||
</g>
|
||||
|
||||
<g className="animate-flutter-left origin-[38px_20px]" style={{ ...wingStyle, animationDelay: `${delay + 0.05}s` }}>
|
||||
<path d="M38 22 Q25 30 18 38 Q28 42 36 34 Q40 28 38 24" fill={color} />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function darken(hex: string, amount: number): string {
|
||||
const num = parseInt(hex.replace('#', ''), 16)
|
||||
const r = Math.max(0, (num >> 16) - amount)
|
||||
const g = Math.max(0, ((num >> 8) & 0x00ff) - amount)
|
||||
const b = Math.max(0, (num & 0x0000ff) - amount)
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`
|
||||
}
|
||||
|
||||
// --- Main FloatingButterfly component with physics ---
|
||||
|
||||
interface FloatingButterflyProps {
|
||||
style?: React.CSSProperties
|
||||
size?: number
|
||||
color?: string
|
||||
delay?: number
|
||||
duration?: number
|
||||
direction?: 'left' | 'right'
|
||||
}
|
||||
|
||||
export function FloatingButterfly({ style, size, color, delay, duration, direction }: FloatingButterflyProps) {
|
||||
// Initialize state lazily to avoid hydration mismatch
|
||||
const initialStateRef = useRef<{ x: number; y: number; heading: number } | null>(null)
|
||||
if (initialStateRef.current === null && typeof window !== 'undefined') {
|
||||
// Random starting position within wander radius
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const dist = Math.random() * WANDER_RADIUS * 0.5
|
||||
initialStateRef.current = {
|
||||
x: Math.cos(angle) * dist,
|
||||
y: Math.sin(angle) * dist,
|
||||
heading: Math.random() * 40 - 10,
|
||||
}
|
||||
}
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [renderState, setRenderState] = useState({ x: 0, y: 0, heading: 0 })
|
||||
|
||||
const stateRef = useRef<ButterflyState | null>(null)
|
||||
const elementRef = useRef<HTMLDivElement>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const wanderAngleRef = useRef(Math.random() * Math.PI * 2)
|
||||
const lastTimeRef = useRef<number | null>(null)
|
||||
|
||||
const cursor = useContext(CursorContext)
|
||||
const cursorRef = useRef<Vec2 | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize physics state on mount with pre-computed random values
|
||||
const init = initialStateRef.current || { x: 0, y: 0, heading: 0 }
|
||||
stateRef.current = {
|
||||
position: vec2(init.x, init.y),
|
||||
velocity: vec2(0, 0),
|
||||
heading: init.heading,
|
||||
targetHeading: init.heading,
|
||||
}
|
||||
// Set initial render state to match physics state
|
||||
setRenderState({ x: init.x, y: init.y, heading: init.heading })
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Keep cursor ref in sync
|
||||
useEffect(() => {
|
||||
cursorRef.current = cursor
|
||||
}, [cursor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
|
||||
function simulate(timestamp: number) {
|
||||
const state = stateRef.current
|
||||
if (!state) return
|
||||
|
||||
// Skip first frame to let things settle, then start tracking time
|
||||
if (lastTimeRef.current === null) {
|
||||
lastTimeRef.current = timestamp
|
||||
rafRef.current = requestAnimationFrame(simulate)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate delta time (cap at 50ms to avoid huge jumps)
|
||||
const dt = Math.min((timestamp - lastTimeRef.current) / 16.67, 3)
|
||||
lastTimeRef.current = timestamp
|
||||
|
||||
let acceleration = vec2(0, 0)
|
||||
|
||||
// 1. Wander behavior - gentle random movement
|
||||
wanderAngleRef.current += (Math.random() - 0.5) * 0.3 * dt
|
||||
const wanderForce = vec2(
|
||||
Math.cos(wanderAngleRef.current) * WANDER_STRENGTH,
|
||||
Math.sin(wanderAngleRef.current) * WANDER_STRENGTH
|
||||
)
|
||||
acceleration = add(acceleration, wanderForce)
|
||||
|
||||
// 2. Return to origin - soft spring force
|
||||
const distFromOrigin = length(state.position)
|
||||
if (distFromOrigin > WANDER_RADIUS * 0.5) {
|
||||
const returnForce = scale(normalize(scale(state.position, -1)), RETURN_STRENGTH * (distFromOrigin / WANDER_RADIUS))
|
||||
acceleration = add(acceleration, returnForce)
|
||||
}
|
||||
|
||||
// 3. Cursor repulsion
|
||||
const currentCursor = cursorRef.current
|
||||
if (currentCursor && elementRef.current) {
|
||||
const rect = elementRef.current.getBoundingClientRect()
|
||||
const worldPos = vec2(rect.left + rect.width / 2 + state.position.x, rect.top + rect.height / 2 + state.position.y)
|
||||
|
||||
const toCursor = sub(worldPos, currentCursor)
|
||||
const distToCursor = length(toCursor)
|
||||
|
||||
if (distToCursor < REPULSION_RADIUS && distToCursor > 0) {
|
||||
// Inverse square falloff for more natural feel
|
||||
const strength = REPULSION_STRENGTH * Math.pow(1 - distToCursor / REPULSION_RADIUS, 2)
|
||||
const repulsionForce = scale(normalize(toCursor), strength)
|
||||
acceleration = add(acceleration, repulsionForce)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Update velocity with acceleration
|
||||
state.velocity = add(state.velocity, scale(acceleration, dt))
|
||||
|
||||
// 5. Apply friction
|
||||
state.velocity = scale(state.velocity, Math.pow(FRICTION, dt))
|
||||
|
||||
// 6. Clamp velocity to max speed
|
||||
const speed = length(state.velocity)
|
||||
if (speed > MAX_SPEED) {
|
||||
state.velocity = scale(normalize(state.velocity), MAX_SPEED)
|
||||
}
|
||||
|
||||
// 7. Update position
|
||||
state.position = add(state.position, scale(state.velocity, dt))
|
||||
|
||||
// 8. Soft clamp position to wander radius
|
||||
const posLen = length(state.position)
|
||||
if (posLen > WANDER_RADIUS) {
|
||||
state.position = scale(normalize(state.position), WANDER_RADIUS)
|
||||
// Dampen velocity when hitting boundary
|
||||
state.velocity = scale(state.velocity, 0.8)
|
||||
}
|
||||
|
||||
// 9. Calculate target heading from velocity direction
|
||||
if (speed > 0.1) {
|
||||
// Convert velocity to angle: 0 = moving down screen (+Y), positive = clockwise
|
||||
const rawHeading = Math.atan2(state.velocity.x, state.velocity.y) * (180 / Math.PI)
|
||||
// Clamp to valid range: -30° to +90° (relative to base 30° rotation)
|
||||
state.targetHeading = Math.max(-30, Math.min(90, rawHeading))
|
||||
}
|
||||
|
||||
// 10. Smoothly interpolate heading towards target (already clamped)
|
||||
state.heading = lerpAngle(state.heading, state.targetHeading, HEADING_SMOOTHING * dt)
|
||||
|
||||
// Update render state
|
||||
setRenderState({
|
||||
x: state.position.x,
|
||||
y: state.position.y,
|
||||
heading: state.heading,
|
||||
})
|
||||
|
||||
rafRef.current = requestAnimationFrame(simulate)
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(simulate)
|
||||
|
||||
return () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}
|
||||
}, [mounted])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elementRef}
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
...style,
|
||||
transform: `translate(${renderState.x}px, ${renderState.y}px)`,
|
||||
}}
|
||||
>
|
||||
<ButterflyVisual size={size} color={color} delay={delay} duration={duration} direction={direction} heading={renderState.heading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as DocsRouteImport } from './routes/docs'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as DocsIndexRouteImport } from './routes/docs/index'
|
||||
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
|
||||
|
||||
const DocsRoute = DocsRouteImport.update({
|
||||
id: '/docs',
|
||||
path: '/docs',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DocsIndexRoute = DocsIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any)
|
||||
const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({
|
||||
id: '/best-practices',
|
||||
path: '/best-practices',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/docs': typeof DocsRouteWithChildren
|
||||
'/docs/best-practices': typeof DocsBestPracticesRoute
|
||||
'/docs/': typeof DocsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/docs/best-practices': typeof DocsBestPracticesRoute
|
||||
'/docs': typeof DocsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/docs': typeof DocsRouteWithChildren
|
||||
'/docs/best-practices': typeof DocsBestPracticesRoute
|
||||
'/docs/': typeof DocsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/docs' | '/docs/best-practices' | '/docs/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/docs/best-practices' | '/docs'
|
||||
id: '__root__' | '/' | '/docs' | '/docs/best-practices' | '/docs/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
DocsRoute: typeof DocsRouteWithChildren
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/docs': {
|
||||
id: '/docs'
|
||||
path: '/docs'
|
||||
fullPath: '/docs'
|
||||
preLoaderRoute: typeof DocsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/docs/': {
|
||||
id: '/docs/'
|
||||
path: '/'
|
||||
fullPath: '/docs/'
|
||||
preLoaderRoute: typeof DocsIndexRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/best-practices': {
|
||||
id: '/docs/best-practices'
|
||||
path: '/best-practices'
|
||||
fullPath: '/docs/best-practices'
|
||||
preLoaderRoute: typeof DocsBestPracticesRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface DocsRouteChildren {
|
||||
DocsBestPracticesRoute: typeof DocsBestPracticesRoute
|
||||
DocsIndexRoute: typeof DocsIndexRoute
|
||||
}
|
||||
|
||||
const DocsRouteChildren: DocsRouteChildren = {
|
||||
DocsBestPracticesRoute: DocsBestPracticesRoute,
|
||||
DocsIndexRoute: DocsIndexRoute,
|
||||
}
|
||||
|
||||
const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
DocsRoute: DocsRouteWithChildren,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export function getRouter() {
|
||||
return createRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
})
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Outlet,
|
||||
createRootRoute,
|
||||
HeadContent,
|
||||
Scripts,
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ name: 'theme-color', content: '#0a0a0a' },
|
||||
],
|
||||
links: [
|
||||
{ rel: 'icon', href: '/favicon.ico', sizes: '48x48' },
|
||||
{ rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' },
|
||||
{ rel: 'apple-touch-icon', href: '/favicon.svg' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' },
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600&display=swap',
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<RootDocument>
|
||||
<Outlet />
|
||||
</RootDocument>
|
||||
)
|
||||
}
|
||||
|
||||
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body className="antialiased bg-background text-foreground">
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--font-serif: 'Playfair Display', serif;
|
||||
|
||||
--color-background: #0a0a0a;
|
||||
--color-foreground: #fafafa;
|
||||
--color-muted: #262626;
|
||||
--color-muted-foreground: #a1a1aa;
|
||||
--color-card: #171717;
|
||||
--color-border: #27272a;
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #27272a;
|
||||
--color-secondary-foreground: #fafafa;
|
||||
|
||||
--animate-flutter-left: flutter-left 0.6s ease-in-out infinite;
|
||||
--animate-flutter-right: flutter-right 0.6s ease-in-out infinite;
|
||||
--animate-float: float 8s ease-in-out infinite;
|
||||
--animate-flutter-body: flutter-body 0.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes flutter-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotateY(0deg) rotateZ(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotateY(50deg) rotateZ(-5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flutter-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotateY(0deg) rotateZ(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotateY(-50deg) rotateZ(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flutter-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-2px) rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-1 {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
20% {
|
||||
transform: translate(12px, -18px) rotate(5deg);
|
||||
}
|
||||
40% {
|
||||
transform: translate(-8px, -30px) rotate(-3deg);
|
||||
}
|
||||
60% {
|
||||
transform: translate(-15px, -12px) rotate(2deg);
|
||||
}
|
||||
80% {
|
||||
transform: translate(5px, 8px) rotate(-4deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-2 {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
15% {
|
||||
transform: translate(-10px, -12px) rotate(-4deg);
|
||||
}
|
||||
35% {
|
||||
transform: translate(8px, -25px) rotate(3deg);
|
||||
}
|
||||
55% {
|
||||
transform: translate(15px, -8px) rotate(-2deg);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-5px, 10px) rotate(5deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-3 {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translate(18px, -10px) rotate(3deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(5px, -35px) rotate(-5deg);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-12px, -15px) rotate(4deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user