Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be5cbd8744 | ||
|
|
9f2186b9f8 | ||
|
|
c3def2c5c4 | ||
|
|
55f7434bf2 | ||
|
|
1500ba1054 | ||
|
|
6f3e3bc858 |
No files matched your search
@@ -1,315 +0,0 @@
|
||||
---
|
||||
name: motion-design
|
||||
description: >
|
||||
Applies motion design principles to create emotionally-driven, technically sound animations and transitions.
|
||||
Provides timing, easing, choreography, and Disney animation principles adapted for UI.
|
||||
Use when creating animations, transitions, micro-interactions, loading states, page transitions,
|
||||
scroll-triggered effects, or any motion work. Works with CSS, Framer Motion, GSAP, Lottie, Spring,
|
||||
or any animation system.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: LottieFiles
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# Motion Design Skill
|
||||
|
||||
## When to Apply
|
||||
|
||||
Use this skill when:
|
||||
- Creating UI animations (buttons, cards, modals, page transitions)
|
||||
- Designing micro-interactions and feedback animations
|
||||
- Building loading, success, or error states
|
||||
- Animating illustrations or decorative elements
|
||||
- Planning scroll-triggered or progress-based animations
|
||||
- Establishing brand motion identity
|
||||
- Choreographing multi-element sequences
|
||||
|
||||
**Decision tree:**
|
||||
1. Does it serve a functional purpose (feedback, guidance)? → Timing rules for responsiveness
|
||||
2. Does it express brand personality? → Motion Personality archetypes
|
||||
3. Does it tell a story or guide attention? → Disney principles + choreography
|
||||
4. Is this a complex multi-element scene? → 1/3 Rule + stagger patterns
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: 8-Step Checklist
|
||||
|
||||
Before creating any animation:
|
||||
|
||||
1. **Emotional target?** — joy, calm, urgency, elegance
|
||||
2. **Motion Personality?** — Playful, Premium, Corporate, Energetic
|
||||
3. **Primary property?** — position, scale, rotation, opacity
|
||||
4. **Duration?** — see duration table below
|
||||
5. **Easing family?** — entrance=decelerate, exit=accelerate
|
||||
6. **Hero element?** — apply staging principles
|
||||
7. **Secondary + ambient layers?** — add richness
|
||||
8. **1/3 rules?** — motion distance, simultaneous elements
|
||||
|
||||
---
|
||||
|
||||
## Three Pillars (CRITICAL)
|
||||
|
||||
Every animation must satisfy three pillars before any technical decisions:
|
||||
|
||||
| Pillar | Question | Drives |
|
||||
|--------|----------|--------|
|
||||
| **Emotional Intent** | What should the viewer FEEL? | Easing, timing, amplitude |
|
||||
| **Visual Narrative** | What's the micro-story? | Setup → Action → Resolution |
|
||||
| **Motion Craft** | How do we make it believable? | Physics, secondary motion, paths |
|
||||
|
||||
**Three motion layers** (flat animation = missing layers):
|
||||
- **Primary**: Main action the viewer follows
|
||||
- **Secondary**: Supporting richness (shadows, icons shifting)
|
||||
- **Ambient**: Background life (gradients, subtle pulses)
|
||||
|
||||
> Deep dive: [director/core-philosophy.md](director/core-philosophy.md)
|
||||
|
||||
---
|
||||
|
||||
## Motion Personality
|
||||
|
||||
Select ONE archetype per project. Apply consistently.
|
||||
|
||||
| Archetype | Duration | Easing | Overshoot | Keywords |
|
||||
|-----------|----------|--------|-----------|----------|
|
||||
| **Playful** | 150-300ms | ease-out-back | 10-20% | fun, whimsical, bouncy, cute |
|
||||
| **Premium** | 350-600ms | cubic-bezier(0.4,0,0.2,1) | 0% | elegant, minimal, luxury, sophisticated |
|
||||
| **Corporate** | 200-400ms | cubic-bezier(0.2,0,0,1) | 0-3% | clean, professional, business, dashboard |
|
||||
| **Energetic** | 100-250ms | ease-out-expo | 15-30% | dynamic, energetic, bold, exciting |
|
||||
|
||||
**Default**: Corporate for UI, Playful for illustrations.
|
||||
|
||||
**Brand Motion Identity** — define three constants:
|
||||
1. **Signature easing**: One curve for 80% of animations
|
||||
2. **Duration palette**: 3 durations (quick / standard / slow)
|
||||
3. **Entrance pattern**: One consistent entry style
|
||||
|
||||
> Deep dive: [director/motion-personality.md](director/motion-personality.md)
|
||||
|
||||
---
|
||||
|
||||
## Property Selection
|
||||
|
||||
| Effect Goal | Primary Property | Secondary Properties |
|
||||
|-------------|------------------|---------------------|
|
||||
| Entrance/Exit | position | opacity, scale |
|
||||
| Emphasis/Attention | scale | rotation (subtle), opacity pulse |
|
||||
| State Change | opacity, color | scale (press feedback) |
|
||||
| Direction/Flow | position | rotation (follow path) |
|
||||
| Depth/3D Feel | scale + shadow | position (parallax) |
|
||||
| Loading/Progress | rotation (spinner) | scale, opacity pulse |
|
||||
| Success | scale (pop) | color, rotation (checkmark draw) |
|
||||
| Error/Alert | position (shake) | color, rotation (wobble) |
|
||||
|
||||
**Simplicity threshold**: Use the minimum properties needed. One = direct. Two = polished. Three+ = potentially overwhelming.
|
||||
|
||||
> Deep dive: [reference/property-selection.md](reference/property-selection.md)
|
||||
|
||||
---
|
||||
|
||||
## Duration Table
|
||||
|
||||
| Element Type | Duration | Rationale |
|
||||
|-------------|----------|-----------|
|
||||
| Tooltip / micro-feedback | 80-120ms | Must feel instant |
|
||||
| Button press / toggle | 120-180ms | Responsive feedback |
|
||||
| Icon transition | 150-250ms | Clear state change |
|
||||
| Card enter / exit | 200-350ms | Spatial awareness |
|
||||
| Modal / dialog | 300-400ms | Focus shift |
|
||||
| Page transition | 400-600ms | Context switch |
|
||||
| Dramatic reveal | 600-1200ms | Theatrical build |
|
||||
|
||||
**Distance scales duration**: 100px = base. 200px = 1.3x. 400px = 1.6x.
|
||||
|
||||
**Enter > Exit**: Entrances 30-50% longer than exits. Users care about what appears.
|
||||
|
||||
**Interactive feedback**:
|
||||
- Hover: <100ms
|
||||
- Press: <150ms
|
||||
- Release/settle: 200-300ms
|
||||
- Error shake: 300-400ms (2-3 oscillations)
|
||||
|
||||
> Deep dive: [reference/timing-easing-tables.md](reference/timing-easing-tables.md)
|
||||
|
||||
---
|
||||
|
||||
## Easing Selection
|
||||
|
||||
**Directional rules**:
|
||||
- **Entrance** → decelerate (fast start, gentle landing): ease-out family
|
||||
- **Exit** → accelerate (gentle start, fast departure): ease-in family
|
||||
- **On-screen** → smooth both ends: ease-in-out family
|
||||
- **Looping ambient** → seamless: sine-based ease-in-out
|
||||
|
||||
**Industry standards**:
|
||||
|
||||
| Standard | Cubic Bezier | Use For |
|
||||
|----------|-------------|---------|
|
||||
| Material Design 3 | (0.2, 0, 0, 1) | Default on-screen |
|
||||
| MD3 Emphasized | (0.05, 0.7, 0.1, 1) | Entrances, attention |
|
||||
| MD3 Accelerate | (0.3, 0, 1, 1) | Exits, dismissals |
|
||||
| Apple HIG | (0.25, 0.1, 0.25, 1) | Standard iOS |
|
||||
| Snappy UI | (0.2, 0, 0, 1) | Fast, decisive |
|
||||
| Gentle float | (0.4, 0, 0.2, 1) | Ambient, background |
|
||||
| Bounce settle | (0.175, 0.885, 0.32, 1.275) | Overshoot, playful |
|
||||
|
||||
**Material-based easing**:
|
||||
|
||||
| Material | Duration Scale | Overshoot |
|
||||
|----------|---------------|-----------|
|
||||
| Rigid (metal, stone) | 1.2x | 0% |
|
||||
| Elastic (rubber, gel) | 0.8x | 15-25% |
|
||||
| Fluid (water, paint) | 1.5x | 5% |
|
||||
| Paper (cards, sheets) | 1.0x | 3-5% |
|
||||
| Gas (smoke, fog) | 2.0x | 0% |
|
||||
| Glass (brittle) | 0.9x | 0% |
|
||||
|
||||
> Deep dive: [reference/timing-easing-tables.md](reference/timing-easing-tables.md)
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Button Press (Playful)
|
||||
1. **Anticipation**: Scale to 0.97 (50ms, ease-out)
|
||||
2. **Squash**: Scale to [1.04, 0.96] (100ms, ease-in)
|
||||
3. **Follow through**: Overshoots to 1.02, settles to 1.0 (spring, 200ms)
|
||||
4. **Secondary**: Shadow shrinks during press, icon shifts down 2px
|
||||
5. **Total**: ~150ms press + 200ms settle
|
||||
|
||||
### Card Entrance (Premium)
|
||||
1. **Start**: 20px below target, opacity 0
|
||||
2. **Path**: Slight curve (10px X offset at midpoint)
|
||||
3. **Easing**: ease-out-cubic deceleration
|
||||
4. **Follow through**: Shadow arrives 50ms after card
|
||||
5. **Secondary**: Content fades in 100ms after card lands
|
||||
6. **Staging**: Other cards dim to 80%
|
||||
|
||||
### Success State (Playful)
|
||||
1. **Primary**: Scale pop with ease-out-back
|
||||
2. **Secondary**: Checkmark draws in
|
||||
3. **Ambient**: Subtle particle burst
|
||||
4. **Color**: Green fill
|
||||
5. **Total**: 300-400ms
|
||||
|
||||
### Error Shake (Corporate)
|
||||
1. **Primary**: Position oscillates 2-3 times, ±10-15px horizontal
|
||||
2. **Easing**: ease-in-out for sharp stops
|
||||
3. **Color**: Red tint
|
||||
4. **Total**: 300-400ms
|
||||
5. **No overshoot**: Errors feel firm
|
||||
|
||||
> More patterns: [patterns/entrance-exit.md](patterns/entrance-exit.md) | [patterns/state-feedback.md](patterns/state-feedback.md)
|
||||
|
||||
---
|
||||
|
||||
## Choreography Essentials
|
||||
|
||||
**Coordinated entry**:
|
||||
- Lead with the hero — primary element enters first or most prominently
|
||||
- Spatial consistency — all elements enter from same direction
|
||||
- Counter-motion — hero moves right → ambient moves left at 20-30% speed
|
||||
|
||||
**1/3 Rule (distance)**: No motion travels more than 1/3 of screen without a keyframe change.
|
||||
|
||||
**1/3 Rule (elements)**: With 3+ elements, no more than 1/3 in active motion simultaneously.
|
||||
|
||||
**Stagger budgets**:
|
||||
|
||||
| Pattern | Delay | Total Budget | Use Case |
|
||||
|---------|-------|-------------|----------|
|
||||
| Micro cascade | 20-40ms | <200ms | List items, grid cells |
|
||||
| Standard | 50-100ms | <400ms | Cards, panels, nav |
|
||||
| Dramatic | 100-200ms | <600ms | Hero sections |
|
||||
| Wave | 30-60ms | <500ms | Data visualizations |
|
||||
|
||||
**Critical**: Total stagger must stay under 500ms.
|
||||
|
||||
> Deep dive: [director/choreography.md](director/choreography.md)
|
||||
|
||||
---
|
||||
|
||||
## Emotion-to-Motion Map
|
||||
|
||||
| Emotion | Character | Path | Easing | Duration |
|
||||
|---------|-----------|------|--------|----------|
|
||||
| Joy | Bouncy, arcs | Curved, upward | ease-out-back | 200-400ms |
|
||||
| Calm | Smooth, flowing | Gentle curves | sine ease-in-out | 500-1000ms |
|
||||
| Urgency | Sharp, fast | Straight lines | ease-out | 100-200ms |
|
||||
| Sadness | Slow, downward | Drooping curves | cubic ease-in-out | 600-1200ms |
|
||||
| Surprise | Sudden, expanding | Radial outward | ease-out-expo | 150-300ms |
|
||||
| Elegance | Slow, controlled | Long arcs | (0.4,0,0.2,1) | 400-700ms |
|
||||
| Playfulness | Bouncy, irregular | Arcs, squiggly | ease-out-back | 200-350ms |
|
||||
|
||||
**Path as language**: Angular = tense. Curved = friendly. Spiral = whimsical. Diagonal = purposeful. Vertical = growth/weight. Horizontal = progress.
|
||||
|
||||
> Deep dive: [director/emotion-mapping.md](director/emotion-mapping.md)
|
||||
|
||||
---
|
||||
|
||||
## Weight Classification
|
||||
|
||||
| Weight | Examples | Duration | Overshoot | Easing |
|
||||
|--------|----------|----------|-----------|--------|
|
||||
| Heavy | Modals, overlays | 300-500ms | 0% | Gentle, high damping |
|
||||
| Medium | Cards, panels | 200-350ms | 3-5% | Moderate |
|
||||
| Light | Tooltips, badges, icons | 80-200ms | 5-15% | Responsive |
|
||||
|
||||
---
|
||||
|
||||
## Quality Rules
|
||||
|
||||
### CRITICAL — never break
|
||||
1. **Never linear for spatial movement** — always use easing curves (linear only for spinners, progress bars)
|
||||
2. **Never opacity-only** for important state changes — combine with position or scale
|
||||
3. **Never exceed 1/3 screen** without intermediate keyframe
|
||||
4. **Always three motion layers** — primary + secondary + ambient
|
||||
|
||||
### HIGH — strongly follow
|
||||
1. Match duration to element type (see tables)
|
||||
2. Use directional easing (ease-out entrance, ease-in exit)
|
||||
3. Apply Disney principles (especially anticipation, follow-through)
|
||||
4. Maintain consistent personality across scene
|
||||
|
||||
> Full checklist: [reference/quality-checklist.md](reference/quality-checklist.md)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Quick Reference
|
||||
|
||||
| Problem | Likely Cause | Fix |
|
||||
|---------|-------------|-----|
|
||||
| Looks robotic | Linear easing or no arcs | Add easing curves + arc paths |
|
||||
| Feels too slow | Duration too long for element type | Check duration table, use ease-out |
|
||||
| Feels cheap/flat | Missing secondary + ambient | Add shadow motion + background life |
|
||||
| Too distracting | Too many elements moving | Apply 1/3 rule, reduce amplitude |
|
||||
| No personality | Generic easing everywhere | Apply personality archetype consistently |
|
||||
|
||||
> Deep dive: [reference/troubleshooting.md](reference/troubleshooting.md)
|
||||
|
||||
---
|
||||
|
||||
## File Reference
|
||||
|
||||
**Philosophy** (director/):
|
||||
- [core-philosophy.md](director/core-philosophy.md) — Three Pillars deep dive
|
||||
- [decision-framework.md](director/decision-framework.md) — Full decision pipeline
|
||||
- [disney-principles.md](director/disney-principles.md) — 12 principles, UI-adapted
|
||||
- [motion-personality.md](director/motion-personality.md) — 4 archetypes + brand identity
|
||||
- [emotion-mapping.md](director/emotion-mapping.md) — Emotion → motion + color psychology
|
||||
- [choreography.md](director/choreography.md) — Multi-element coordination
|
||||
- [narrative-structure.md](director/narrative-structure.md) — Micro-story framework
|
||||
- [context-adaptation.md](director/context-adaptation.md) — Platform, a11y, performance
|
||||
|
||||
**Reference** (reference/):
|
||||
- [timing-easing-tables.md](reference/timing-easing-tables.md) — Duration + easing lookups
|
||||
- [property-selection.md](reference/property-selection.md) — Property communication guide
|
||||
- [troubleshooting.md](reference/troubleshooting.md) — Animation smells + fixes
|
||||
- [quality-checklist.md](reference/quality-checklist.md) — Evaluation criteria
|
||||
|
||||
**Patterns** (patterns/):
|
||||
- [entrance-exit.md](patterns/entrance-exit.md) — Entrance/exit recipes
|
||||
- [state-feedback.md](patterns/state-feedback.md) — Success, error, loading, hover
|
||||
- [ambient-continuous.md](patterns/ambient-continuous.md) — Looping, breathing, parallax
|
||||
- [multi-element.md](patterns/multi-element.md) — Stagger + choreography recipes
|
||||
@@ -1,93 +0,0 @@
|
||||
# Choreography
|
||||
|
||||
## Coordinated Entry Rules
|
||||
|
||||
### 1. Lead with the Hero
|
||||
- Hero gets largest displacement and most attention-grabbing easing
|
||||
- Supporting elements are subtler in every dimension
|
||||
|
||||
### 2. Spatial Origin Consistency
|
||||
All elements enter from same direction or shared origin. Mixed directions = chaos.
|
||||
|
||||
### 3. Counter-Motion
|
||||
|
||||
| Hero Motion | Counter-Motion | Speed Ratio |
|
||||
|-------------|---------------|-------------|
|
||||
| Enters left | Background shifts right | 20-30% |
|
||||
| Scales up | Shadow scales down | 10-20% |
|
||||
| Rotates CW | Ambient drifts CCW | 15-25% |
|
||||
| Lifts (Y up) | Shadow spreads + softens | 20-30% |
|
||||
|
||||
## Sequence Structure
|
||||
|
||||
| Phase | Duration Share | What Happens |
|
||||
|-------|--------------|-------------|
|
||||
| Setup | 20-30% | Elements enter, scene establishes |
|
||||
| Action | 30-40% | Primary motion, hero moment |
|
||||
| Resolution | 30-40% | Settle, secondary reactions, breathing |
|
||||
|
||||
Leave 100-200ms stillness after resolution before new motion.
|
||||
|
||||
## The 1/3 Rules
|
||||
|
||||
**Distance**: No motion travels >1/3 screen without intermediate keyframe. Break with direction changes, speed variations, or arc adjustments.
|
||||
|
||||
**Elements**: With 3+ animated elements, max 1/3 active simultaneously. Stagger so element 1 settles as element 3 starts.
|
||||
|
||||
## Stagger Patterns
|
||||
|
||||
| Pattern | Description | Best For |
|
||||
|---------|------------|----------|
|
||||
| Sequential | Reading order | Lists, grids |
|
||||
| Center-out | Radiating from center | Hero content, ripples |
|
||||
| Random | Varied timing | Organic, particle-like |
|
||||
| Wave | Sine-based | Data bars, continuous |
|
||||
| Reverse | Bottom-to-top | Exits, backward nav |
|
||||
|
||||
- All staggered elements use same easing family
|
||||
- Vary only start time, not curve
|
||||
- Optional: last element gets slight overshoot (punctuation)
|
||||
|
||||
## Shared Motion Events
|
||||
|
||||
When multiple elements react to one trigger:
|
||||
- All start within 50ms of each other
|
||||
- Can arrive at different times (staggered landing)
|
||||
- Same easing family; motion originates from trigger point
|
||||
|
||||
## Attention Direction
|
||||
|
||||
| Technique | Implementation |
|
||||
|-----------|---------------|
|
||||
| Leading motion | Animate target before context |
|
||||
| Following motion | Settle on focal point |
|
||||
| Ambient motion | Subtle continuous in periphery |
|
||||
| Pointing motion | Directional toward CTA |
|
||||
|
||||
### Depth Through Speed
|
||||
|
||||
| Layer | Displacement | Speed |
|
||||
|-------|-------------|-------|
|
||||
| Foreground | 1.0x | Fastest |
|
||||
| Midground | 0.5x | Medium |
|
||||
| Background | 0.2x | Slowest |
|
||||
|
||||
## Common Recipes
|
||||
|
||||
### Dashboard Load
|
||||
1. Skeletons fade in (100ms)
|
||||
2. Hero metric (250ms, ease-out, 100ms delay)
|
||||
3. Supporting cards stagger (50ms between, 200ms each)
|
||||
4. Chart data draws in (300ms, starts with cards)
|
||||
5. Ambient pulse on primary metric
|
||||
|
||||
### Modal Open
|
||||
1. Background dims (200ms)
|
||||
2. Modal scales 95%→100% + fades (300ms, 50ms delay)
|
||||
3. Content fades in (200ms, 100ms after modal)
|
||||
4. Close button last (150ms)
|
||||
|
||||
### List Update (item added)
|
||||
1. Existing items shift down (200ms, ease-in-out)
|
||||
2. New item fades+slides from top (250ms, ease-out, 50ms delay)
|
||||
3. Subtle scale overshoot on land (3-5%)
|
||||
@@ -1,83 +0,0 @@
|
||||
# Context Adaptation
|
||||
|
||||
## Platform Scaling
|
||||
|
||||
| Platform | Duration Modifier | Complexity | Physics |
|
||||
|----------|------------------|------------|---------|
|
||||
| Desktop | 1.0x (baseline) | Full | All types |
|
||||
| Tablet | 0.9x | Standard | Most types |
|
||||
| Mobile | 0.8x | Reduced (1-2 properties) | Snappy only |
|
||||
| Watch | 0.6x | Minimal (1 property) | None |
|
||||
| TV/Kiosk | 1.3x | Full | All types |
|
||||
|
||||
**Mobile rules**: prefer opacity + transform; touch feedback <100ms; reduce stagger budgets by 30%; avoid parallax
|
||||
**Desktop opportunities**: hover states, cursor tracking, multi-column stagger, spatial choreography
|
||||
|
||||
## Accessibility
|
||||
|
||||
### prefers-reduced-motion
|
||||
|
||||
| Original Motion | Reduced Alternative |
|
||||
|----------------|-------------------|
|
||||
| Slide entrance | Opacity fade only |
|
||||
| Bounce/spring | Instant or simple ease-out |
|
||||
| Parallax | Static positioning |
|
||||
| Auto-playing | Paused, user-initiated |
|
||||
| Complex choreography | Single fade |
|
||||
| Continuous ambient | Static or subtle opacity pulse |
|
||||
|
||||
Reduced motion means: remove spatial movement, keep opacity, remove spring easing, reduce duration 50%+, never auto-play loops.
|
||||
|
||||
### Vestibular Triggers (avoid or provide alternatives)
|
||||
- Large-scale zoom, full-screen position transitions
|
||||
- Spinning elements >100px, parallax >2 layers, rapid direction changes
|
||||
|
||||
### Cognitive Accessibility
|
||||
- Same interaction = same animation every time
|
||||
- Pause controls for animations >5 seconds
|
||||
- Don't convey critical info through motion alone
|
||||
|
||||
## Performance Budgets
|
||||
|
||||
| Tier | Properties | Max Elements |
|
||||
|------|-----------|-------------|
|
||||
| Optimal | transform, opacity | Unlimited (GPU) |
|
||||
| Good | + color, clip-path | 10-15 |
|
||||
| Acceptable | + width, height, margin | 5-8 |
|
||||
| Avoid | box-shadow, border-radius, filter | 1-3 |
|
||||
|
||||
- Target 60fps (16.67ms/frame); animation logic <10ms/frame
|
||||
- will-change sparingly; keep animated elements <20 per viewport
|
||||
- Stagger reduces peak load vs simultaneous
|
||||
- Fallback: 30fps acceptable for ambient
|
||||
|
||||
## Content Type Adaptation
|
||||
|
||||
| Content Type | Personality | Duration | Motion Density |
|
||||
|-------------|-------------|----------|---------------|
|
||||
| Financial | Corporate/Premium | 250-500ms | Low |
|
||||
| Social media | Playful | 150-300ms | Medium |
|
||||
| Enterprise SaaS | Corporate | 200-400ms | Low |
|
||||
| Gaming | Energetic | 100-250ms | High |
|
||||
| Healthcare | Corporate/Calm | 300-600ms | Very low |
|
||||
| E-commerce | Varies | 200-400ms | Medium |
|
||||
| Editorial | Premium | 350-600ms | Low |
|
||||
| Children's apps | Playful | 150-300ms | High |
|
||||
|
||||
## Responsive Motion
|
||||
|
||||
| Container Width | Max Displacement | Duration |
|
||||
|----------------|-----------------|----------|
|
||||
| <400px | 20% of width | 0.8x |
|
||||
| 400-800px | 25% of width | 1.0x |
|
||||
| 800-1200px | 20% of width | 1.0x |
|
||||
| >1200px | 15% of width | 1.1x |
|
||||
|
||||
- Small viewport: sequential, one element at a time
|
||||
- Medium: standard stagger, 2-3 columns
|
||||
- Large: full choreography, center-out stagger, parallax
|
||||
|
||||
## Dark Mode
|
||||
- Reduce motion intensity 10-20% (bright on dark = more impact)
|
||||
- Subtler ambient motion; careful with opacity values
|
||||
- Avoid pure white flashes
|
||||
@@ -1,53 +0,0 @@
|
||||
# Core Philosophy
|
||||
|
||||
## Three Pillars
|
||||
|
||||
### Pillar 1: Emotional Intent
|
||||
Define target emotion before choosing any property.
|
||||
|
||||
| Emotion | Character | Timing | Easing |
|
||||
|---------|----------|--------|--------|
|
||||
| Trust | Smooth, predictable | 300-400ms | Gentle curves |
|
||||
| Delight | Bouncy, surprising | 200-300ms | Overshoot |
|
||||
| Urgency | Sharp, direct | 100-200ms | Snappy ease-out |
|
||||
| Calm | Slow, flowing | 500-1000ms | Sine curves |
|
||||
| Surprise | Sudden, explosive | 150-300ms | Exponential |
|
||||
| Confidence | Direct, decisive | 200-400ms | Strong ease-out |
|
||||
|
||||
### Pillar 2: Visual Narrative
|
||||
|
||||
| Phase | Duration Share | Purpose |
|
||||
|-------|--------------|---------|
|
||||
| Setup | 20-30% | Establish context, prepare viewer |
|
||||
| Action | 30-40% | Primary motion, hero moment |
|
||||
| Resolution | 30-40% | Settle, breathe, confirm |
|
||||
|
||||
Even a 200ms tooltip fade has implicit setup→action→resolution.
|
||||
|
||||
### Pillar 3: Motion Craft
|
||||
- Easing curves match emotional intent
|
||||
- Duration proportional to element size and distance
|
||||
- Arcs for organic, straight for mechanical
|
||||
- Secondary motion (shadows, related elements)
|
||||
- Nothing starts and stops all at once
|
||||
|
||||
## Three Motion Layers
|
||||
|
||||
| Layer | Role | Amplitude |
|
||||
|-------|------|-----------|
|
||||
| Primary | Main action viewer follows | 100% |
|
||||
| Secondary | Supporting richness | 30-50% |
|
||||
| Ambient | Background life | 10-20% |
|
||||
|
||||
- Secondary offset 50-100ms from primary, different easing
|
||||
- Ambient is continuous/slow, never demands attention
|
||||
- Primary-only animation feels flat; always add secondary + ambient
|
||||
|
||||
## The 1/3 Screen Rule
|
||||
No motion travels >1/3 screen without intermediate keyframe. Break with direction changes, speed shifts, or arc adjustments.
|
||||
|
||||
## The Attention Budget
|
||||
- One hero motion per scene moment
|
||||
- Max 2-3 elements in active motion simultaneously
|
||||
- Ambient doesn't count against budget
|
||||
- Stagger rather than synchronize
|
||||
@@ -1,91 +0,0 @@
|
||||
# Decision Framework
|
||||
|
||||
## The First Questions
|
||||
|
||||
### 1. Purpose?
|
||||
|
||||
| Purpose | Approach | Priority |
|
||||
|---------|----------|----------|
|
||||
| Draw attention | Scale, color, pulse | Responsiveness |
|
||||
| Guide sequence | Stagger, directional flow | Clarity |
|
||||
| Provide feedback | Immediate response | Speed |
|
||||
| Create atmosphere | Subtle, continuous | Subtlety |
|
||||
| Explain relationship | Position, connection | Legibility |
|
||||
| Celebrate/reward | Overshoot, particles | Delight |
|
||||
|
||||
### 2. Audience?
|
||||
- Stressed → calm motion; Browsing → richer motion
|
||||
- Task-focused → fast, minimal; Exploring → can be dramatic
|
||||
- Seen 100x/day → fast, subtle; Seen once → can be dramatic
|
||||
|
||||
### 3. Context?
|
||||
- Busy layout → simpler motion; Empty → can be dramatic
|
||||
- Other animations active → coordinate or stagger
|
||||
- Small container → small motion
|
||||
|
||||
## 4-Level Decision Hierarchy
|
||||
|
||||
### Level 1: Motion Category
|
||||
|
||||
| Category | When |
|
||||
|----------|------|
|
||||
| Revealing | Content appearing |
|
||||
| Concealing | Content disappearing |
|
||||
| Transitioning | Between states/views |
|
||||
| Emphasizing | Drawing attention |
|
||||
| Responding | Reacting to interaction |
|
||||
| Ambient | Background atmosphere |
|
||||
|
||||
### Level 2: Motion Personality
|
||||
Match to brand. See [motion-personality.md](motion-personality.md).
|
||||
|
||||
### Level 3: Motion Direction
|
||||
|
||||
| Direction | Communicates |
|
||||
|-----------|-------------|
|
||||
| Upward | Growth, aspiration |
|
||||
| Downward | Settling, completion |
|
||||
| Leftward | Regression, departure |
|
||||
| Rightward | Progression, arrival |
|
||||
| Outward (scale up) | Importance, emergence |
|
||||
| Inward (scale down) | Dismissal, removal |
|
||||
| Center-seeking | Focus, convergence |
|
||||
| Center-fleeing | Distribution, release |
|
||||
|
||||
### Level 4: Implementation Properties
|
||||
Choose animated properties. See [property-selection.md](../reference/property-selection.md).
|
||||
|
||||
## Decision Quick-Path
|
||||
|
||||
```
|
||||
1. PURPOSE → What does this motion DO?
|
||||
├── Feedback → short, ease-out, minimal
|
||||
├── Entrance → ease-out, personality-matched
|
||||
├── Exit → ease-in, 70% of entrance
|
||||
├── Emphasis → scale or color pulse
|
||||
└── Ambient → slow, continuous, sine
|
||||
|
||||
2. PERSONALITY → Match keywords
|
||||
├── Fun/bouncy → Playful
|
||||
├── Elegant/luxury → Premium
|
||||
├── Clean/professional → Corporate
|
||||
└── Bold/dynamic → Energetic
|
||||
|
||||
3. PROPERTIES → Minimum needed (two = sweet spot)
|
||||
|
||||
4. TIMING → Check duration table, adjust for distance/weight
|
||||
|
||||
5. EASING → Entering=ease-out, Leaving=ease-in, On-screen=ease-in-out, Looping=sine
|
||||
```
|
||||
|
||||
## Evaluation Before Delivery
|
||||
|
||||
| Check | Question |
|
||||
|-------|----------|
|
||||
| Purpose | Clear function? |
|
||||
| Personality | Matches brand/context? |
|
||||
| Repetition | Fine on 100th viewing? |
|
||||
| Attention | Respects viewer's focus? |
|
||||
| Hierarchy | Reinforces importance? |
|
||||
| Layers | Primary + secondary + ambient? |
|
||||
| Timing | Duration appropriate? |
|
||||
@@ -1,102 +0,0 @@
|
||||
# Disney's 12 Principles — UI Adapted
|
||||
|
||||
## 1. Squash and Stretch
|
||||
|
||||
- Squash: scale ~[1.2, 0.8]; Stretch: ~[0.85, 1.15]
|
||||
- Impact: 2-4 frames (30-65ms); Recovery: 4-8 frames (65-130ms)
|
||||
- Preserve volume: width +20% → height decreases proportionally
|
||||
- Skip for premium/luxury brands
|
||||
|
||||
## 2. Anticipation
|
||||
|
||||
- Small motion opposite to main direction before action
|
||||
- Duration: 100-200ms, magnitude: 10-20% of main action
|
||||
- Button: scale down 3% before expanding; Card: shift 5-10px away first
|
||||
- Skip for micro-feedback (<150ms)
|
||||
|
||||
## 3. Staging
|
||||
|
||||
- Dim non-hero elements to 40-60% opacity; optional 2-4px blur
|
||||
- Hero enters 100-200ms after supporting elements
|
||||
- One primary action per timing beat
|
||||
|
||||
## 4. Straight Ahead vs. Pose to Pose
|
||||
|
||||
| Approach | Feel | Best For |
|
||||
|----------|------|----------|
|
||||
| Straight Ahead | Fluid, spontaneous | Particles, ambient, generative art |
|
||||
| Pose to Pose | Planned, controlled | UI transitions, state changes |
|
||||
|
||||
## 5. Follow Through and Overlapping Action
|
||||
|
||||
- Child delay: 50-150ms behind parent
|
||||
- Trailing elements: offset stop times by 100-200ms
|
||||
- Use spring easing for trailing parts (lower stiffness = more trailing)
|
||||
|
||||
## 6. Slow In and Slow Out
|
||||
|
||||
| Context | Easing | Why |
|
||||
|---------|--------|-----|
|
||||
| Entrance | ease-out | Arrives smoothly |
|
||||
| Exit | ease-in | Departs quickly |
|
||||
| On-screen | ease-in-out | Smooth journey |
|
||||
| Ambient loop | sine ease-in-out | Seamless |
|
||||
|
||||
**NEVER** linear for spatial movement. Linear only for: rotation, progress bars, timers.
|
||||
|
||||
## 7. Arcs
|
||||
|
||||
- Add 10-20px perpendicular offset at path midpoint
|
||||
- Subtle (5px) for corporate, pronounced (20px+) for playful
|
||||
- Mechanical UIs can use straight paths intentionally
|
||||
|
||||
## 8. Secondary Action
|
||||
|
||||
- Amplitude: 30-50% of primary; timing: 50-100ms after primary
|
||||
- Different easing than primary
|
||||
- Examples: card enters → shadow grows; button presses → ripple expands
|
||||
|
||||
## 9. Timing
|
||||
|
||||
| Weight/Mood | Duration |
|
||||
|-------------|----------|
|
||||
| Heavy (modals, pages) | 400-800ms |
|
||||
| Light (tooltips, toggles) | 100-250ms |
|
||||
| Sad/serious | 600ms+ |
|
||||
| Happy/light | 200-400ms |
|
||||
| Urgent | 100-200ms |
|
||||
|
||||
Enter-exit asymmetry: entrances 30-50% longer than exits.
|
||||
|
||||
## 10. Exaggeration
|
||||
|
||||
| Personality | Exaggeration |
|
||||
|-------------|-------------|
|
||||
| Playful | 15-25% |
|
||||
| Energetic | 20-30% |
|
||||
| Corporate | 0-5% |
|
||||
| Premium | 0% |
|
||||
|
||||
- Scale overshoot: 10-30% beyond target; rotation: ±5-15°
|
||||
|
||||
## 11. Solid Drawing
|
||||
|
||||
- Maintain consistent proportions across keyframes
|
||||
- Use scale + rotation together for depth
|
||||
- Shadow behavior matches implied light source
|
||||
|
||||
## 12. Appeal
|
||||
|
||||
- Smooth curves over sharp angles; satisfying timing
|
||||
- Personality consistency across all elements
|
||||
- Appeal killers: jerky motion, inconsistent timing, abrupt stops, uniform animation
|
||||
|
||||
## Combining Principles
|
||||
|
||||
| Recipe | Principles Used |
|
||||
|--------|----------------|
|
||||
| Button press | Anticipation + Squash/Stretch + Follow-Through + Secondary + Timing |
|
||||
| Card entrance | Anticipation + Arcs + Slow In/Out + Follow-Through + Staging |
|
||||
| Success celebration | Exaggeration + Secondary + Timing + Squash/Stretch + Appeal |
|
||||
| Error shake | Timing + Slow In/Out + Staging (no exaggeration) |
|
||||
| Loading spinner | Timing + Slow In/Out + Appeal |
|
||||
@@ -1,71 +0,0 @@
|
||||
# Emotion-to-Motion Mapping
|
||||
|
||||
## Core Table
|
||||
|
||||
| Emotion | Character | Path | Easing | Duration |
|
||||
|---------|----------|------|--------|----------|
|
||||
| Joy/Delight | Bouncy, arcs, overshoot | Curved, upward | ease-out-back | 200-400ms |
|
||||
| Calm/Serenity | Smooth, flowing | Gentle curves | sine ease-in-out | 500-1000ms |
|
||||
| Urgency/Alert | Sharp, fast, direct | Straight lines | ease-out | 100-200ms |
|
||||
| Sadness/Weight | Slow, downward | Drooping curves | cubic ease-in-out | 600-1200ms |
|
||||
| Surprise/Impact | Sudden, expanding | Radial outward | ease-out-expo | 150-300ms |
|
||||
| Elegance/Grace | Slow, controlled | Long smooth arcs | (0.4, 0, 0.2, 1) | 400-700ms |
|
||||
| Playfulness | Bouncy, irregular | Arcs, squiggly | ease-out-back | 200-350ms |
|
||||
| Confidence | Direct, decisive | Straight, horizontal | ease-out | 200-400ms |
|
||||
| Curiosity | Exploratory, varied | Mixed, circular | varied | 300-500ms |
|
||||
| Tenderness | Soft, gentle | Very subtle curves | soft ease-in-out | 600-1000ms |
|
||||
|
||||
## Path as Emotional Language
|
||||
|
||||
| Path Type | Connotation |
|
||||
|-----------|------------|
|
||||
| Angular/sharp | Tense, urgent, mechanical |
|
||||
| Curved/smooth | Relaxed, friendly, organic |
|
||||
| Spiral | Playful, whimsical |
|
||||
| Straight diagonal | Dynamic, purposeful |
|
||||
| Vertical up | Growth, achievement |
|
||||
| Vertical down | Settling, gravity |
|
||||
| Horizontal | Journey, progress |
|
||||
| Radial outward | Explosion, release |
|
||||
| Radial inward | Focus, convergence |
|
||||
|
||||
## Emotional Intensity
|
||||
|
||||
| Intensity | Characteristics | When |
|
||||
|-----------|----------------|------|
|
||||
| Low | Subtle opacity, tiny shifts | Ambient, routine |
|
||||
| Medium | Visible but not demanding | Most UI interactions |
|
||||
| High | Demands attention, large displacement | Errors, celebrations, onboarding |
|
||||
|
||||
## Color Psychology
|
||||
|
||||
| Color | Emotion | Animation Pairing |
|
||||
|-------|---------|------------------|
|
||||
| Blue | Trust, calm | Smooth, medium transitions |
|
||||
| Green | Success, growth | Upward, expansion, gentle overshoot |
|
||||
| Red | Alert, urgency | Sharp, fast, horizontal shakes |
|
||||
| Orange | Energy, warmth | Bouncy, diagonal paths |
|
||||
| Purple | Premium, mystery | Slow reveals, elegant easing |
|
||||
| Yellow | Optimism, caution | Quick pulses |
|
||||
| Teal | Modern, clarity | Clean, snappy transitions |
|
||||
|
||||
### Color Transition Rules
|
||||
- Success: transition TO green (don't start with it)
|
||||
- Error: flash red then settle (don't sustain)
|
||||
- Warning: pulse yellow/amber for urgency
|
||||
- Neutral: use opacity rather than color change
|
||||
|
||||
## Context-Based Emotion Defaults
|
||||
|
||||
| Context | Default Emotion |
|
||||
|---------|----------------|
|
||||
| Form success | Joy + Confidence |
|
||||
| Validation error | Mild urgency |
|
||||
| Page load | Calm + Confidence |
|
||||
| Navigation | Confidence |
|
||||
| Notification | Mild surprise |
|
||||
| Loading | Calm |
|
||||
| Onboarding | Curiosity + Delight |
|
||||
| Dashboard | Calm + Confidence |
|
||||
| Purchase complete | Joy + Confidence |
|
||||
| Delete/remove | Calm (respectful departure) |
|
||||
@@ -1,89 +0,0 @@
|
||||
# Motion Personality
|
||||
|
||||
## Four Archetypes
|
||||
|
||||
### Playful
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Duration | 150-300ms |
|
||||
| Easing | ease-out-back / bouncy springs |
|
||||
| Overshoot | 10-20% |
|
||||
| Paths | Arcs and curves, never straight |
|
||||
| Squash-stretch | Yes, on impacts |
|
||||
|
||||
Signature: bounce settle, squash-stretch on press, rotation wobble, bright color pops, varied stagger timing.
|
||||
Use for: children's apps, casual games, social media, celebrations, onboarding, creative tools.
|
||||
|
||||
### Premium / Luxury
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Duration | 350-600ms |
|
||||
| Easing | cubic-bezier(0.4, 0, 0.2, 1) |
|
||||
| Overshoot | 0% |
|
||||
| Paths | Smooth curves, subtle parallax |
|
||||
| Squash-stretch | Never |
|
||||
|
||||
Signature: slow fades, subtle scale (98%→100%), generous pauses, minimal properties (opacity+one), ultra-smooth.
|
||||
Use for: fashion, finance, luxury brands, premium SaaS, portfolios, editorial.
|
||||
|
||||
### Corporate / Professional
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Duration | 200-400ms |
|
||||
| Easing | cubic-bezier(0.2, 0, 0, 1) |
|
||||
| Overshoot | 0-3% |
|
||||
| Paths | Mostly straight, small arcs for emphasis |
|
||||
| Squash-stretch | No |
|
||||
|
||||
Signature: consistent timing, clear state transitions, functional motion, predictable patterns, uniform stagger.
|
||||
Use for: enterprise, dashboards, business tools, admin, healthcare, banking.
|
||||
|
||||
### Energetic / Dynamic
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Duration | 100-250ms |
|
||||
| Easing | ease-out-expo / elastic |
|
||||
| Overshoot | 15-30% |
|
||||
| Paths | Dramatic arcs, large displacement, diagonal |
|
||||
| Squash-stretch | Yes, exaggerated |
|
||||
|
||||
Signature: large scale changes (50-150%), fast color transitions, particle bursts, accelerating stagger, bold edge entrances.
|
||||
Use for: gaming, sports, music, events, marketing, fitness apps.
|
||||
|
||||
## Keyword Matching
|
||||
|
||||
| Keywords | Archetype |
|
||||
|----------|-----------|
|
||||
| fun, whimsical, bouncy, cute, friendly | Playful |
|
||||
| elegant, minimal, luxury, sophisticated | Premium |
|
||||
| clean, professional, business, dashboard | Corporate |
|
||||
| dynamic, energetic, bold, exciting | Energetic |
|
||||
| (unspecified) + UI | Corporate (default) |
|
||||
| (unspecified) + illustration | Playful (default) |
|
||||
|
||||
## Brand Motion Identity
|
||||
|
||||
Define three constants for recognizable motion:
|
||||
|
||||
### 1. Signature Easing (80% of animations)
|
||||
Playful: ease-out-back | Premium: (0.4,0,0.2,1) | Corporate: (0.2,0,0,1) | Energetic: ease-out-expo
|
||||
|
||||
### 2. Duration Palette
|
||||
|
||||
| Tier | Playful | Premium | Corporate | Energetic |
|
||||
|------|---------|---------|-----------|-----------|
|
||||
| Quick | 150ms | 350ms | 200ms | 100ms |
|
||||
| Standard | 250ms | 500ms | 300ms | 180ms |
|
||||
| Slow | 400ms | 800ms | 450ms | 300ms |
|
||||
|
||||
### 3. Entrance Pattern
|
||||
Playful: bounce up from below | Premium: slow fade + scale 98%→100% | Corporate: slide right + opacity | Energetic: snap from edge + overshoot
|
||||
|
||||
## Mixing Archetypes
|
||||
- 90% primary archetype; specific moments can borrow another
|
||||
- Ease into personality shifts, don't snap
|
||||
- Example: corporate dashboard borrows Playful for success state only
|
||||
@@ -1,62 +0,0 @@
|
||||
# Narrative Structure
|
||||
|
||||
## Four-Act Structure
|
||||
|
||||
### Act 1: Anticipation (10-20%)
|
||||
- Wind-up: small motion opposite to main direction
|
||||
- Gathering: elements pull together before dispersing
|
||||
- Dimming: context fades for focus; Tension: hold compressed ~50ms
|
||||
- Skip for <150ms interactions and hover states
|
||||
|
||||
### Act 2: Action (30-50%)
|
||||
Peak energy. The primary communicative motion.
|
||||
- Fast+direct (sharp easing) → alerts
|
||||
- Smooth+flowing (gentle easing, curves) → transitions
|
||||
- Explosive+expanding (expo, radial) → celebrations
|
||||
- Controlled+precise (linear-ish) → data charts
|
||||
|
||||
### Act 3: Reaction (10-20%)
|
||||
- Shadows adjust: 50-100ms after primary
|
||||
- Siblings shift: 50-150ms after primary
|
||||
- Environment ripples: 100-200ms after primary
|
||||
- Counter-motion: simultaneous with action
|
||||
- Skip for simple toggles/checkboxes
|
||||
|
||||
### Act 4: Resolution (20-30%)
|
||||
- Overshoot settle (spring/back-easing); opacity reaches final (ease-out)
|
||||
- 100-200ms breathing room before next motion
|
||||
- Even 50ms of settling transforms the feel
|
||||
|
||||
## Scaling to Duration
|
||||
|
||||
| Total | Anticipation | Action | Reaction | Resolution |
|
||||
|-------|-------------|--------|----------|------------|
|
||||
| 100-200ms | skip | 60-70% | skip | 30-40% |
|
||||
| 200-400ms | 10-15% | 40-50% | 10-15% | 25-30% |
|
||||
| 400-800ms | 15-20% | 30-40% | 15-20% | 20-25% |
|
||||
| 800ms+ | 20% | 30-35% | 15-20% | 25-30% |
|
||||
|
||||
## Multi-Beat Narratives
|
||||
|
||||
Transitions: overlap (fluid) | sequential (clear) | simultaneous (parallel)
|
||||
|
||||
Progressions:
|
||||
- **Build-Up**: low → rising → peak → settling
|
||||
- **Cycle**: depart → peak → return → repeat
|
||||
- **Impact**: sudden action → ripple → slow settle
|
||||
|
||||
## By Personality
|
||||
|
||||
| Personality | Anticipation | Action | Resolution |
|
||||
|------------|-------------|--------|------------|
|
||||
| Playful | Exaggerated wind-up | Bouncy, overshoot | Wobble settle |
|
||||
| Premium | Subtle tension | Smooth, controlled | Elegant ease |
|
||||
| Corporate | Minimal/none | Direct, efficient | Clean stop |
|
||||
| Energetic | Quick gather | Explosive | Fast snap |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- **Reveal**: tension (dim, scale 95%) → emerge (100%, full opacity) → surroundings adjust → settled
|
||||
- **Departure**: gather (scale 98%) → exit → close gap → layout settles
|
||||
- **Transformation**: destabilize (vibration) → morph → secondary appears → new state breathing
|
||||
- **Celebration**: compress (50-100ms) → burst (scale, particles) → settle → calm with positive state
|
||||
@@ -1,81 +0,0 @@
|
||||
# Ambient & Continuous Patterns
|
||||
|
||||
Ambient amplitude: 10-20% of primary motion. Never compete for attention.
|
||||
|
||||
## Breathing / Pulse
|
||||
|
||||
- Scale oscillation 0.98-1.02, sine ease-in-out, 2000-4000ms/cycle
|
||||
- Alt: opacity oscillation 0.7-1.0
|
||||
|
||||
| Context | Scale Range | Duration | Opacity Range |
|
||||
|---------|-----------|----------|--------------|
|
||||
| Active indicator | 0.95-1.05 | 2000ms | 0.6-1.0 |
|
||||
| Waiting/idle | 0.98-1.02 | 3000ms | 0.8-1.0 |
|
||||
| Background element | 0.99-1.01 | 4000ms | 0.9-1.0 |
|
||||
| CTA attention | 0.97-1.03 | 2500ms | 0.7-1.0 |
|
||||
|
||||
Pulsing >±5% scale becomes attention-demanding.
|
||||
|
||||
## Floating / Hovering
|
||||
|
||||
- Y position ±5-15px, sine ease-in-out, 3000-5000ms/cycle
|
||||
- Optional: slight rotation ±2-3°, offset 30% of position cycle
|
||||
|
||||
**Layered floating** (multiple elements — different durations prevent sync):
|
||||
- Element 1: 4000ms, ±10px
|
||||
- Element 2: 5500ms, ±8px (offset 30%)
|
||||
- Element 3: 3500ms, ±12px (offset 60%)
|
||||
|
||||
## Gradient Shift
|
||||
|
||||
- Background-position or gradient angle shift
|
||||
- Duration: 8000-20000ms/cycle; range: ±10-20% position or ±15° angle
|
||||
- Easing: linear or sine; should be imperceptible at a glance
|
||||
|
||||
## Parallax
|
||||
|
||||
| Layer | Speed Ratio | Content |
|
||||
|-------|------------|---------|
|
||||
| Foreground | 1.0x | Interactive, text |
|
||||
| Midground | 0.5x | Decorative, cards |
|
||||
| Background | 0.2x | Patterns, scenery |
|
||||
| Deep background | 0.1x | Texture, atmospheric |
|
||||
|
||||
**Scroll-driven**: total displacement <100px; avoid on mobile; never parallax text
|
||||
**Mouse-driven**: foreground 10-20px max, background 5-10px opposite direction, 100-200ms interpolation
|
||||
|
||||
## Continuous Rotation
|
||||
|
||||
- Spinners: linear easing, 1000-2000ms/revolution
|
||||
|
||||
| Variant | Speed | Use Case |
|
||||
|---------|-------|----------|
|
||||
| Slow spin | 10-30s/rev | Background decoration |
|
||||
| Gear-like | 3-5s/rev | Technical/mechanical |
|
||||
| Orbital | 5-15s/rev | Space/science theme |
|
||||
| Wobble | 2-4s/cycle, sine | Playful idle |
|
||||
|
||||
## Shimmer / Gleam
|
||||
|
||||
- Gradient sweep left-to-right, 1500-2500ms/sweep
|
||||
- Pause 2000-5000ms between sweeps
|
||||
- Opacity gradient: 0%→30%→0%
|
||||
- Use for: skeleton loading, premium accents, "new" badges
|
||||
|
||||
## Particle Ambient
|
||||
|
||||
### Snow/Falling: 10-20 elements, 20-60px/s down, ±10-20px drift, 3-8px, 30-70% opacity
|
||||
### Dust/Motes: 5-10 elements, 10-30px/s mixed, 2-5px, 20-50% opacity
|
||||
### Sparkle/Stars: 8-15 elements, opacity pulse 0→100%→0, 500-1500ms/sparkle, random stagger
|
||||
|
||||
**Performance**: <20 ambient elements, transform+opacity only, larger/fewer over small/numerous
|
||||
|
||||
## Combining Ambient Layers
|
||||
|
||||
| Layer | Type | Example |
|
||||
|-------|------|---------|
|
||||
| Background | Gradient shift or parallax | Slow color temperature change |
|
||||
| Midground | Floating or particles | Gentle floating elements |
|
||||
| Foreground | Breathing or shimmer | Subtle pulse on content |
|
||||
|
||||
Total ambient: max 20% of primary motion's visual energy.
|
||||
@@ -1,82 +0,0 @@
|
||||
# Entrance & Exit Patterns
|
||||
|
||||
## Entrance Strategies
|
||||
|
||||
### 1. Direct Entrance (Slide In)
|
||||
- Position + opacity; offset 20-40px + opacity 0 → final position + opacity 1
|
||||
- Easing: ease-out; duration 200-350ms
|
||||
|
||||
| Personality | Offset | Easing | Overshoot |
|
||||
|------------|--------|--------|-----------|
|
||||
| Playful | 30-50px | ease-out-back | 10-15% |
|
||||
| Premium | 15-25px | (0.4, 0, 0.2, 1) | 0% |
|
||||
| Corporate | 20-30px | (0.2, 0, 0, 1) | 0-3% |
|
||||
| Energetic | 40-80px | ease-out-expo | 15-25% |
|
||||
|
||||
Direction: below=arrival, right=forward, left=back, above=dropdown/authority.
|
||||
|
||||
### 2. Emergent Entrance (Scale In)
|
||||
- Scale + opacity; start 85-95% + opacity 0 → 100% + opacity 1
|
||||
- Duration 250-400ms
|
||||
|
||||
| Personality | Start Scale | Easing | Overshoot |
|
||||
|------------|------------|--------|-----------|
|
||||
| Playful | 70-80% | ease-out-back | 10-20% |
|
||||
| Premium | 95-98% | (0.4, 0, 0.2, 1) | 0% |
|
||||
| Corporate | 90-95% | (0.2, 0, 0, 1) | 0-3% |
|
||||
| Energetic | 50-70% | ease-out-expo | 15-30% |
|
||||
|
||||
Best for: modals, dialogs, notifications, popovers, tooltips.
|
||||
|
||||
### 3. Reveal Entrance (Clip/Mask)
|
||||
- clip-path or mask + opacity; 300-500ms, ease-out
|
||||
- Directions: top-to-bottom (dramatic), L-to-R (reading order), center-out (focus), edge-in (contained)
|
||||
|
||||
### 4. Assembled Entrance (Multi-Part)
|
||||
- Parts arrive from different positions; stagger 50-100ms; total 300-600ms
|
||||
- Best for: icon assembly, logo builds, data viz construction
|
||||
|
||||
## Exit Strategies
|
||||
|
||||
**Rule**: Exits = 65-75% of entrance duration.
|
||||
|
||||
### 1. Direct Exit (Slide Out)
|
||||
- Offset 20-40px + opacity 0; ease-in; 150-250ms
|
||||
|
||||
### 2. Dissolve Exit (Fade Out)
|
||||
- Opacity (+ optional scale to 98%); ease-in; 150-250ms
|
||||
- Best for: gentle departures, backgrounding, crossfades
|
||||
|
||||
### 3. Collapse Exit (Shrink Out)
|
||||
- Scale 85-95% + opacity 0; ease-in; 150-250ms
|
||||
- Best for: deletion, closing modals, dismissal
|
||||
|
||||
### 4. Transfer Exit (Move Away)
|
||||
- Position toward destination + scale shrink; ease-in-out; 250-400ms
|
||||
- Best for: add-to-cart, save-to-collection, move-to-folder
|
||||
|
||||
## Entrance-Exit Continuity
|
||||
- Eye follows naturally from exit to entrance
|
||||
- Exit point near entry point when possible
|
||||
- 100-150ms timing overlap between exit and entrance
|
||||
- Same easing family for paired entrance-exit
|
||||
|
||||
## Common Recipes
|
||||
|
||||
### Notification Slide-In
|
||||
1. Slide from right + opacity (250ms, ease-out)
|
||||
2. Overshoot 3-5% (corporate) or 10-15% (playful)
|
||||
3. Icon appears (100ms, 50ms delay)
|
||||
|
||||
### Toast Dismiss
|
||||
1. Slide toward edge + opacity (180ms, ease-in)
|
||||
2. Remaining toasts shift up (200ms, ease-in-out)
|
||||
|
||||
### Dropdown Open
|
||||
1. Scale Y 0→100% (200ms, ease-out)
|
||||
2. Items stagger fade in (30ms apart, 50ms after container)
|
||||
|
||||
### Page Transition (Forward)
|
||||
1. Current page slides left + fades (300ms, ease-in)
|
||||
2. New page from right + fades in (400ms, ease-out, 100ms delay)
|
||||
3. Shared elements morph (400ms, ease-in-out)
|
||||
@@ -1,69 +0,0 @@
|
||||
# Multi-Element Patterns
|
||||
|
||||
## Stagger Recipes
|
||||
|
||||
### List Items
|
||||
- Slide up 20px + fade (200ms, ease-out); stagger 40-60ms; total <400ms for 8 items
|
||||
|
||||
| Personality | Duration | Stagger | Easing |
|
||||
|------------|---------|---------|--------|
|
||||
| Playful | 250ms | 60ms | ease-out-back |
|
||||
| Premium | 350ms | 80ms | (0.4, 0, 0.2, 1) |
|
||||
| Corporate | 200ms | 50ms | (0.2, 0, 0, 1) |
|
||||
| Energetic | 150ms | 30ms | ease-out-expo |
|
||||
|
||||
### Grid Cards
|
||||
- Scale from 95% + fade (250ms, ease-out); stagger 50-80ms reading order; +20ms per new row
|
||||
- Shadow 50ms after card. Alt patterns: center-out, column-first, random
|
||||
|
||||
### Navigation Items
|
||||
- Slide from side + fade (180ms, ease-out); stagger 30-50ms; total <300ms
|
||||
|
||||
### Dashboard Widgets
|
||||
```
|
||||
0ms — Skeletons visible
|
||||
100ms — Hero metric (250ms, ease-out)
|
||||
200ms — Widgets begin (200ms each, 60ms stagger)
|
||||
350ms — Hero complete, chart draws (300ms)
|
||||
500ms — All landed
|
||||
650ms — Ambient pulse begins
|
||||
```
|
||||
|
||||
## Coordinated Sequences
|
||||
|
||||
### Modal with Content
|
||||
0ms: backdrop dims (200ms) → 50ms: modal scales 95%→100% (300ms) → 200ms: title → 280ms: body → 350ms: buttons → 400ms: close button. Content in reading order.
|
||||
|
||||
### Tab Switch
|
||||
0ms: indicator slides (250ms, ease-in-out) + old fades (150ms) → 100ms: new content from tab direction (200ms) → 150ms: elements stagger (40ms)
|
||||
|
||||
### Accordion
|
||||
Expand: arrow rotates (150ms) + height expands (250ms) + content fades at 50ms (200ms); siblings shift (200ms). Collapse: reverse, ease-in.
|
||||
|
||||
### Page Transition
|
||||
Current slides left+fades (300ms, ease-in) → new from right (400ms, ease-out, 100ms delay) → hero scales in → content staggers (50ms). Optional shared element morph (400ms).
|
||||
|
||||
### Drag and Drop
|
||||
Drag: lift (scale 1.03, 150ms); others shift (200ms). Drop: settle (scale 1.0, 200ms); gaps close.
|
||||
|
||||
## Choreography Rules
|
||||
|
||||
### Timing Overlap
|
||||
0%=methodical | 25%=brisk (standard) | 50%=fluid | 75%=rapid (energetic)
|
||||
|
||||
### Group Rules
|
||||
- Same easing family; different durations/delays fine
|
||||
- Exception: secondary/ambient can differ
|
||||
|
||||
### Shared Origin
|
||||
Motion from trigger point; closer=first; farther=more delay
|
||||
|
||||
### Counter-Motion
|
||||
|
||||
| Hero Does | Counter-Motion |
|
||||
|-----------|---------------|
|
||||
| Slides right | Background left (20-30%) |
|
||||
| Scales up | Shadow spreads |
|
||||
| Lifts up | Shadow drops+softens |
|
||||
| Rotates CW | Ambient CCW |
|
||||
| Expands | Siblings compress |
|
||||
@@ -1,96 +0,0 @@
|
||||
# State & Feedback Patterns
|
||||
|
||||
## Button Press
|
||||
|
||||
### Playful
|
||||
- Press: scale 0.95 (60ms, ease-out); Release: overshoot 1.05 (80ms); Settle: 1.0 (120ms, spring)
|
||||
- Secondary: shadow shrinks/grows; color darkens/brightens. Total ~260ms
|
||||
|
||||
### Premium
|
||||
- Press: scale 0.98 (80ms); Release: 1.0 (150ms, no overshoot)
|
||||
- Opacity dims to 90% on press. Total ~230ms
|
||||
|
||||
### Corporate
|
||||
- Press: scale 0.97 (60ms); Release: 1.0 (100ms); overshoot 0-2%
|
||||
- Background darkens 10%. Total ~160ms
|
||||
|
||||
## Hover States
|
||||
|
||||
| Element | Effect | Duration |
|
||||
|---------|--------|----------|
|
||||
| Button | Scale 1.02-1.05 | <100ms |
|
||||
| Card | Scale 1.01-1.02 + shadow lift | <100ms |
|
||||
| Link | Color change + underline | <100ms |
|
||||
| Icon | Scale 1.1 + rotation 2-5° | <100ms |
|
||||
| Image | Scale 1.03 (overflow hidden) | 150ms |
|
||||
|
||||
Hover enter <100ms; hover exit 150-200ms (slower = polished).
|
||||
|
||||
## Toggle / Switch
|
||||
- Thumb slides (120-180ms, ease-in-out); track color transitions simultaneously
|
||||
- Slight squash in movement direction
|
||||
- Playful: bounce at destination; Premium: smooth, no overshoot
|
||||
|
||||
## Success State
|
||||
|
||||
### Checkmark Success
|
||||
1. Container: scale 0.9→1.0 (200ms, ease-out-back, 5-10% overshoot)
|
||||
2. Checkmark: stroke draw (150ms, ease-out, 100ms delay)
|
||||
3. Color: to green (200ms); ambient glow/particles (300ms)
|
||||
4. Total: 400-500ms
|
||||
|
||||
### Confirmation Badge
|
||||
1. Badge scales from 0 (200ms, ease-out-back)
|
||||
2. Text fades in (150ms, 50ms delay)
|
||||
3. Background pulse (300ms, sine)
|
||||
|
||||
### Payment Success
|
||||
1. Spinner → checkmark crossfade (200ms)
|
||||
2. Checkmark draws (200ms); container → success color (200ms)
|
||||
3. Text fades in (200ms, 100ms delay); optional confetti (300-500ms)
|
||||
|
||||
## Error State
|
||||
|
||||
### Error Shake
|
||||
- Horizontal oscillation ±10-15px, 2-3 cycles decreasing amplitude
|
||||
- ease-in-out, 300-400ms total; red tint; no overshoot; settles at origin
|
||||
|
||||
### Inline Validation
|
||||
1. Error text slides down + fades in (200ms)
|
||||
2. Border → red (150ms); icon scales in (150ms, 50ms delay)
|
||||
3. Optional single shake (200ms)
|
||||
|
||||
### Form Submission Error
|
||||
1. Button returns to normal (200ms)
|
||||
2. Error message slides in (250ms)
|
||||
3. Affected fields highlight red (150ms, staggered 30ms)
|
||||
4. Smooth scroll to first error (300ms, ease-in-out)
|
||||
|
||||
## Loading States
|
||||
|
||||
### Spinner
|
||||
- Continuous 360°, linear, 1000-1500ms/rev; optional breathing pulse (2-3s)
|
||||
|
||||
### Skeleton
|
||||
- Gradient sweep L→R, 1500-2000ms; base 10-20% opacity, peak 30-40%
|
||||
|
||||
### Progress Bar
|
||||
- Width/transform, ease-in-out; optional color milestones + shimmer
|
||||
|
||||
### Indeterminate
|
||||
- Oscillating position/width, 1500-2500ms, ease-in-out; continuous, never frantic
|
||||
|
||||
## Warning State
|
||||
1. Yellow/amber border (150ms)
|
||||
2. Warning icon scales in (150ms, subtle overshoot)
|
||||
3. Optional icon pulse (2-3s, sine); text fades in (200ms, 50ms delay)
|
||||
|
||||
## Disabled / Enabled
|
||||
|
||||
- **Disabling**: opacity to 50-60% (200ms); optional scale to 98%
|
||||
- **Enabling**: opacity to 100% (200ms); optional scale pulse 98%→100%
|
||||
|
||||
## Focus States
|
||||
- Focus ring: scale 95%→100% + opacity (150ms)
|
||||
- Card focus: scale 1.01, shadow increase (150ms)
|
||||
- Tab nav focus: <100ms; must work with reduced-motion
|
||||
@@ -1,95 +0,0 @@
|
||||
# Property Selection
|
||||
|
||||
## Position
|
||||
|
||||
| Direction | Meaning |
|
||||
|-----------|---------|
|
||||
| Upward | Growth, improvement, aspiration |
|
||||
| Downward | Settling, completion, grounding |
|
||||
| Leftward | Regression, backward, departure |
|
||||
| Rightward | Progression, forward, arrival |
|
||||
| Toward center | Focus, convergence |
|
||||
| Away from center | Distribution, expansion |
|
||||
|
||||
## Scale
|
||||
|
||||
| Direction | Meaning |
|
||||
|-----------|---------|
|
||||
| Scale up | Importance, activation, proximity |
|
||||
| Scale down | Deprioritization, distance |
|
||||
| Pulse | Attention, heartbeat, life |
|
||||
| Breathing | Presence, waiting |
|
||||
|
||||
## Rotation
|
||||
|
||||
| Range | Meaning |
|
||||
|-------|---------|
|
||||
| Small (5-15°) | Subtle adjustment |
|
||||
| Medium (45-90°) | Transformation |
|
||||
| Full (360°) | Completion, processing |
|
||||
| Continuous | Ongoing activity |
|
||||
|
||||
## Opacity
|
||||
|
||||
| Direction | Meaning |
|
||||
|-----------|---------|
|
||||
| Fade in | Arrival, enablement |
|
||||
| Fade out | Departure, disablement |
|
||||
| Partial | Secondary, disabled state |
|
||||
|
||||
**Rule**: NEVER opacity alone for important state changes. Combine with position or scale.
|
||||
|
||||
## Color
|
||||
|
||||
| Transition | Meaning |
|
||||
|-----------|---------|
|
||||
| To green | Success, go |
|
||||
| To red | Error, stop |
|
||||
| To blue | Trust, active |
|
||||
| To gray | Disabled, inactive |
|
||||
| Brightening | Activation, focus |
|
||||
| Dimming | Deactivation, background |
|
||||
|
||||
## Combined Properties
|
||||
|
||||
| Combination | Best For |
|
||||
|-------------|----------|
|
||||
| Position + Opacity | Content appearing/disappearing |
|
||||
| Scale + Opacity | Cards, modals, notifications |
|
||||
| Position + Scale | Selected items, focused content |
|
||||
| Rotation + Scale | Celebrations, playful activation |
|
||||
| Position + Rotation | Organic transitions |
|
||||
| Color + Scale | State emphasis |
|
||||
|
||||
Primary property carries meaning; secondary adds polish. Two properties is the sweet spot.
|
||||
|
||||
## Property Selection by Goal
|
||||
|
||||
| Goal | Primary | Secondary | Avoid |
|
||||
|------|---------|-----------|-------|
|
||||
| Entrance | position | opacity | rotation |
|
||||
| Exit | position | opacity | scale up |
|
||||
| Button press | scale | color | position |
|
||||
| Hover | scale or color | opacity | position |
|
||||
| Success | scale | color + opacity | position |
|
||||
| Error | position (shake) | color | scale |
|
||||
| Loading | rotation | opacity | position |
|
||||
| Toggle | position | color | rotation |
|
||||
| Notification | position + scale | opacity | rotation |
|
||||
| Delete | scale + opacity | position | grow |
|
||||
| Selection | scale | color, opacity | rotation |
|
||||
| Progress | position or scale | color | opacity |
|
||||
|
||||
## Performance
|
||||
|
||||
| Property | Performance |
|
||||
|----------|-------------|
|
||||
| transform (translate, scale, rotate) | Excellent — GPU-accelerated |
|
||||
| opacity | Excellent — GPU-accelerated |
|
||||
| color / background-color | Good — triggers paint |
|
||||
| clip-path | Good — GPU on modern browsers |
|
||||
| width / height | Poor — triggers layout |
|
||||
| margin / padding | Poor — triggers layout |
|
||||
| box-shadow | Poor — expensive paint |
|
||||
|
||||
**Rule**: Prefer transform + opacity for all motion.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Quality Checklist
|
||||
|
||||
## Visual Quality
|
||||
- [ ] Elements >40px for motion, >100px for detail
|
||||
- [ ] Readable at full speed without slow-motion
|
||||
- [ ] Clear primary, secondary, ambient layers
|
||||
- [ ] Counter-motion for balance where needed
|
||||
- [ ] Natural arcs (unless intentionally mechanical)
|
||||
- [ ] 1/3 rule (distance): no unbroken motion >1/3 container
|
||||
- [ ] 1/3 rule (density): max 1/3 elements active simultaneously
|
||||
|
||||
## Technical Quality
|
||||
- [ ] No linear easing on spatial movement
|
||||
- [ ] Duration matches element type table
|
||||
- [ ] Ease-out entrances, ease-in exits
|
||||
- [ ] Duration proportional to distance
|
||||
- [ ] Entrance duration >= exit duration
|
||||
- [ ] Not opacity-only for important state changes
|
||||
- [ ] Stagger total <500ms
|
||||
- [ ] Follow-through: child elements offset 50-150ms
|
||||
|
||||
## Emotional Quality
|
||||
- [ ] Target emotion identified before properties
|
||||
- [ ] Personality archetype matches brand
|
||||
- [ ] Setup → action → resolution structure
|
||||
- [ ] Intensity matches interaction importance
|
||||
- [ ] Consistent: same interaction = same motion
|
||||
- [ ] Appropriate on 100th viewing
|
||||
|
||||
## Performance Quality
|
||||
- [ ] Primary motion uses transform + opacity
|
||||
- [ ] <20 animated elements per viewport
|
||||
- [ ] No layout-triggering properties animated
|
||||
- [ ] Elements staggered, not simultaneous
|
||||
- [ ] Maintains 60fps (30fps acceptable for ambient)
|
||||
|
||||
## Accessibility Quality
|
||||
- [ ] prefers-reduced-motion alternative provided
|
||||
- [ ] No vestibular triggers without alternative
|
||||
- [ ] Same interaction = same animation
|
||||
- [ ] Critical info not motion-only
|
||||
- [ ] Animations >5s are pausable
|
||||
|
||||
## Severity Tiers
|
||||
|
||||
### CRITICAL
|
||||
- Linear easing on spatial movement
|
||||
- Opacity-only for important states
|
||||
- Exceeds 1/3 screen rule
|
||||
- Missing primary layer
|
||||
- Stagger >500ms
|
||||
- Layout property animation causing jank
|
||||
|
||||
### HIGH
|
||||
- Missing secondary layer
|
||||
- Duration mismatch with element type
|
||||
- Wrong directional easing
|
||||
- Inconsistent personality
|
||||
- No follow-through
|
||||
- Missing reduced-motion alternative
|
||||
|
||||
### MEDIUM
|
||||
- Missing ambient layer
|
||||
- No anticipation phase
|
||||
- Overshoot mismatch
|
||||
- Could use better arcs
|
||||
- Missing counter-motion
|
||||
@@ -1,106 +0,0 @@
|
||||
# Timing & Easing Tables
|
||||
|
||||
## Duration by Element Type
|
||||
|
||||
| Element | Duration |
|
||||
|---------|----------|
|
||||
| Tooltip / micro-feedback | 80-120ms |
|
||||
| Button / toggle | 120-180ms |
|
||||
| Icon transition | 150-250ms |
|
||||
| Card enter/exit | 200-350ms |
|
||||
| Modal / dialog | 300-400ms |
|
||||
| Page transition | 400-600ms |
|
||||
| Dramatic reveal | 600-1200ms |
|
||||
| Ambient | 2000-20000ms |
|
||||
|
||||
## Distance-Duration Scaling
|
||||
|
||||
50px=0.8x | 100px=1.0x | 200px=1.3x | 300px=1.5x | 400px=1.6x | Full screen=1.8-2.0x
|
||||
|
||||
## Enter vs. Exit
|
||||
Entrance = base (100%). Exit = 65-75% of entrance.
|
||||
|
||||
## Interactive Feedback
|
||||
|
||||
| Interaction | Max Latency |
|
||||
|------------|-------------|
|
||||
| Hover | <100ms |
|
||||
| Press/tap | <150ms |
|
||||
| Release/settle | 200-300ms |
|
||||
| Error shake | 300-400ms |
|
||||
| Long press | 500-800ms |
|
||||
| Drag start | <50ms |
|
||||
|
||||
## Duration by Personality
|
||||
|
||||
| Personality | Quick | Standard | Slow |
|
||||
|------------|-------|----------|------|
|
||||
| Playful | 150ms | 250ms | 400ms |
|
||||
| Premium | 350ms | 500ms | 800ms |
|
||||
| Corporate | 200ms | 300ms | 450ms |
|
||||
| Energetic | 100ms | 180ms | 300ms |
|
||||
|
||||
## Easing: Directional Rules
|
||||
|
||||
Entrance=ease-out | Exit=ease-in | On-screen=ease-in-out | Looping=sine | Rotation/progress=linear
|
||||
|
||||
## Easing: Industry Standards
|
||||
|
||||
| Name | Cubic Bezier | Use |
|
||||
|------|-------------|-----|
|
||||
| MD3 Standard | (0.2, 0, 0, 1) | Default on-screen |
|
||||
| MD3 Emphasized | (0.05, 0.7, 0.1, 1) | Entrances |
|
||||
| MD3 Accelerate | (0.3, 0, 1, 1) | Exits |
|
||||
| MD3 Decelerate | (0, 0, 0, 1) | Entering |
|
||||
| Apple HIG | (0.25, 0.1, 0.25, 1) | iOS default |
|
||||
| Apple Spring | stiffness:300 damping:20 | Interactive |
|
||||
| Snappy UI | (0.2, 0, 0, 1) | Fast, decisive |
|
||||
| Gentle float | (0.4, 0, 0.2, 1) | Ambient |
|
||||
| Bounce settle | (0.175, 0.885, 0.32, 1.275) | Playful |
|
||||
| Elastic snap | (0.68, -0.55, 0.265, 1.55) | Dramatic |
|
||||
|
||||
## Material-Based Easing
|
||||
|
||||
| Material | Duration Scale | Overshoot |
|
||||
|----------|---------------|-----------|
|
||||
| Rigid (metal) | 1.2x | 0% |
|
||||
| Elastic (rubber) | 0.8x | 15-25% |
|
||||
| Fluid (water) | 1.5x | 5% |
|
||||
| Paper (cards) | 1.0x | 3-5% |
|
||||
| Gas (smoke) | 2.0x | 0% |
|
||||
| Glass | 0.9x | 0% |
|
||||
|
||||
## Spring Parameters
|
||||
|
||||
| Feel | Stiffness | Damping |
|
||||
|------|-----------|---------|
|
||||
| Very stiff | 400+ | 25-30 |
|
||||
| Standard | 250-350 | 18-24 |
|
||||
| Bouncy | 150-250 | 10-15 |
|
||||
| Very bouncy | 100-200 | 5-10 |
|
||||
| Gentle | 100-150 | 20-25 |
|
||||
|
||||
Damping: <1.0=oscillates, 1.0=fastest no-oscillation, >1.0=slow settle.
|
||||
|
||||
## Stagger Patterns
|
||||
|
||||
| Pattern | Delay | Budget |
|
||||
|---------|------|--------|
|
||||
| Micro cascade | 20-40ms | <200ms |
|
||||
| Standard | 50-100ms | <400ms |
|
||||
| Dramatic | 100-200ms | <600ms |
|
||||
| Wave | 30-60ms | <500ms |
|
||||
|
||||
Direction: top-to-bottom (lists) | L-to-R (horizontal) | center-out (hero) | random (organic) | reverse (exits)
|
||||
|
||||
Total stagger MUST stay <500ms.
|
||||
|
||||
## Overshoot Budget
|
||||
|
||||
| Context | Overshoot |
|
||||
|---------|-----------|
|
||||
| Success | 5-10% |
|
||||
| Error | 0% |
|
||||
| Feedback | 2-5% |
|
||||
| Celebration | 15-25% |
|
||||
| Premium | 0% |
|
||||
@@ -1,73 +0,0 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Looks Robotic
|
||||
- Linear easing → use ease-out (entrance) or ease-in-out
|
||||
- Straight paths → add 10-20px arc at midpoint
|
||||
- Uniform timing → stagger 50-100ms between elements
|
||||
- Everything synced → offset start/stop 50-150ms
|
||||
- No secondary → add shadow, icon reaction, ambient
|
||||
|
||||
## Feels Too Slow
|
||||
- Duration exceeds type budget → check duration table
|
||||
- ease-in-out when ease-out works → ease-out feels faster
|
||||
- Too much anticipation → reduce to 10% or remove
|
||||
- Stagger exceeded → total <500ms
|
||||
- Overshoot settle too long → increase damping
|
||||
|
||||
## Feels Too Fast / Jarring
|
||||
- Duration below minimum → modals 300ms+, pages 400ms+
|
||||
- No easing → add ease-out minimum
|
||||
- Missing resolution → add 50-100ms settle
|
||||
- No anticipation on large motion → add 100-200ms wind-up
|
||||
|
||||
## Feels Cheap / Flat
|
||||
- Only primary motion → add secondary + ambient
|
||||
- Opacity-only → combine with position or scale
|
||||
- Same easing everywhere → vary primary vs secondary
|
||||
- No follow-through → child elements trail 50-150ms
|
||||
- No overshoot → add 3-10%
|
||||
|
||||
## Too Distracting
|
||||
- Too many moving → 1/3 rule
|
||||
- Amplitude too large → reduce to minimum
|
||||
- Competing heroes → one per moment, dim rest
|
||||
- Ambient too prominent → 10-20% amplitude, slower
|
||||
- No breathing room → 100-200ms pause between beats
|
||||
|
||||
## No Personality
|
||||
- Default easing → apply archetype's signature
|
||||
- Same duration for all → use personality palette
|
||||
- No consistent entrance → define one for project
|
||||
- Mixed archetypes → pick one for 90%+
|
||||
|
||||
## Inconsistent Feel
|
||||
- Different easing same-type → standardize per motion type
|
||||
- Duration varies same type → use palette consistently
|
||||
- Entry direction changes → one origin everywhere
|
||||
- Overshoot inconsistent → apply rules consistently
|
||||
|
||||
## Performance (Dropped Frames)
|
||||
- Animating width/height/margin → use transform
|
||||
- Too many elements → <20 per viewport
|
||||
- Complex shadows/filters → simplify or pre-render
|
||||
- No GPU acceleration → transform + opacity
|
||||
- All simultaneous → stagger to spread load
|
||||
|
||||
## Quick Diagnostic
|
||||
|
||||
1. No linear on spatial movement
|
||||
2. Duration matches element type
|
||||
3. Primary + secondary layers
|
||||
4. Consistent personality
|
||||
5. Directional easing correct
|
||||
6. 1/3 screen rule
|
||||
7. 1/3 element rule
|
||||
8. Follow-through present
|
||||
9. Every motion has purpose
|
||||
10. Fine on 100th viewing
|
||||
|
||||
## Personality Mistakes
|
||||
- **Playful**: overshoot >25% = broken; not everything bounces; short+bounce = glitchy
|
||||
- **Premium**: too subtle = invisible; too slow = waiting; zero = broken
|
||||
- **Corporate**: too conservative = boring; playful easing breaks trust; identical = monotonous
|
||||
- **Energetic**: max everywhere = nothing stands out; too many particles; no settle = chaos
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/motion-design
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
name: remotion-video
|
||||
description: Create videos from React code with Remotion. Use when the user asks to make, animate, preview, or render a video, motion graphic, intro, caption, or social clip using code.
|
||||
---
|
||||
|
||||
# Remotion Video
|
||||
|
||||
Remotion makes videos out of React components. Each frame is a web page. Render them in order and you get an MP4.
|
||||
|
||||
## Core ideas
|
||||
- **Composition**: one video. Has `width`, `height`, `fps`, `durationInFrames`.
|
||||
- **Frame**: `useCurrentFrame()` returns the current frame number. All animation is a function of the frame.
|
||||
- **`interpolate`**: maps a frame range to a value range (fade, slide).
|
||||
- **`spring`**: natural bouncy motion.
|
||||
- **`Sequence`**: shows children only during a frame range.
|
||||
- **`AbsoluteFill`**: full-screen layer; stack them for backgrounds.
|
||||
- **`Audio` / `OffthreadVideo` / `Img`**: media. Put files in `public/` and load with `staticFile("name.mp3")`.
|
||||
|
||||
## Workflow
|
||||
1. **Plan first.** Ask or decide: length (seconds), size (1920x1080, or 1080x1920 for vertical), fps (30), and the scenes. Keep scenes to a short list.
|
||||
2. **Set up** (skip if the project exists):
|
||||
```
|
||||
npx create-video@latest
|
||||
cd <project> && npm install
|
||||
```
|
||||
3. **Write** compositions in `src/`, register them in `src/Root.tsx`.
|
||||
4. **Preview**: `npm run dev` (opens Remotion Studio).
|
||||
5. **Render**: `npx remotion render <CompositionId> out/video.mp4`
|
||||
Still frame: `npx remotion still <CompositionId> out/frame.png --frame=30`
|
||||
6. **Verify**: render one still first, check it looks right, then render the full video.
|
||||
|
||||
## Starter example
|
||||
`src/Root.tsx`
|
||||
```tsx
|
||||
import { Composition } from "remotion";
|
||||
import { Hello } from "./Hello";
|
||||
|
||||
export const RemotionRoot = () => (
|
||||
<Composition
|
||||
id="Hello"
|
||||
component={Hello}
|
||||
durationInFrames={150}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{ title: "Hello, world" }}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
`src/Hello.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
export const Hello = ({ title }: { title: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
|
||||
const scale = spring({ frame, fps, config: { damping: 12 } });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0b1020", justifyContent: "center", alignItems: "center" }}>
|
||||
<h1 style={{ color: "white", fontSize: 120, opacity, transform: `scale(${scale})` }}>{title}</h1>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Rules of thumb
|
||||
- Never animate with CSS transitions, `setTimeout`, or CSS animations. They don't render per frame. Drive everything from `useCurrentFrame()`.
|
||||
- Always pass `extrapolateRight: "clamp"` to `interpolate` unless you want values to keep growing.
|
||||
- Seconds to frames: `seconds * fps`.
|
||||
- Use `<Sequence from={..} durationInFrames={..}>` to order scenes; `<Series>` to chain them.
|
||||
- Load fonts with `@remotion/google-fonts` so they are ready before rendering.
|
||||
- Keep props in `defaultProps` so the user can edit text in the Studio.
|
||||
- Rendering needs Chromium and ffmpeg (Remotion installs/handles these); first render can be slow.
|
||||
|
||||
## Fonts
|
||||
Google Fonts: `npm i @remotion/google-fonts`, then
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Inter";
|
||||
const { fontFamily } = loadFont(); // use as style={{ fontFamily }}
|
||||
```
|
||||
It downloads the font while rendering, so the machine needs internet access to fonts.googleapis.com. If the render fails with "Failed to load font", the network is blocking it: use a local font file in `public/` with `@remotion/fonts` (`loadFont({ family, url: staticFile("x.woff2") })`) or a system font. (Not verified in the sandbox where this skill was tested, which had no access to Google.)
|
||||
|
||||
## Common problems
|
||||
- **Can't download the browser (no internet / locked-down machine)**: point Remotion at an installed Chromium:
|
||||
`npx remotion render <Id> out/video.mp4 --browser-executable=/path/to/chromium --chrome-mode=chrome-for-testing`
|
||||
- **Blank/white frames**: an asset path is wrong; use `staticFile()`.
|
||||
- **Flicker**: an image or font not loaded; use `<Img>` and `@remotion/google-fonts`.
|
||||
- **Slow render**: lower resolution/fps while testing, or use `--concurrency`.
|
||||
|
||||
## Templates
|
||||
Copy-paste starting points. Read the one that fits:
|
||||
- `templates/captions.md` - words highlighted in time with speech
|
||||
- `templates/charts.md` - animated bar chart
|
||||
- `templates/intro-outro.md` - title card + chaining scenes with `Series`
|
||||
- `templates/audio-voiceover.md` - background music, voiceover, auto video length
|
||||
- `templates/vertical.md` - 1080x1920 clips for Reels/TikTok/Shorts
|
||||
|
||||
## Docs
|
||||
https://www.remotion.dev/docs
|
||||
@@ -1,62 +0,0 @@
|
||||
# Template: Audio, music and voiceover
|
||||
|
||||
Put sound files in `public/` (mp3 or wav). Load them with `staticFile()`.
|
||||
|
||||
`src/Narrated.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, Audio, Sequence, interpolate, staticFile, useVideoConfig } from "remotion";
|
||||
import { getAudioDurationInSeconds } from "@remotion/media-utils";
|
||||
import type { CalculateMetadataFunction } from "remotion";
|
||||
|
||||
type Props = { title: string; voice: string; music: string };
|
||||
|
||||
// Make the video exactly as long as the voiceover (+1 second of padding).
|
||||
export const calcNarratedMetadata: CalculateMetadataFunction<Props> = async ({ props }) => {
|
||||
const seconds = await getAudioDurationInSeconds(staticFile(props.voice));
|
||||
return { durationInFrames: Math.ceil((seconds + 1) * 30) };
|
||||
};
|
||||
|
||||
export const Narrated = ({ title, voice, music }: Props) => {
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "#0b1020", justifyContent: "center", alignItems: "center" }}>
|
||||
<h1 style={{ color: "white", fontSize: 110 }}>{title}</h1>
|
||||
|
||||
{/* Background music: quiet, fades out over the last second. */}
|
||||
<Audio
|
||||
src={staticFile(music)}
|
||||
volume={(f) => interpolate(f, [durationInFrames - fps, durationInFrames], [0.15, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" })}
|
||||
/>
|
||||
|
||||
{/* Voiceover starts half a second in, full volume. */}
|
||||
<Sequence from={Math.round(fps * 0.5)}>
|
||||
<Audio src={staticFile(voice)} volume={1} />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Register it (note `calculateMetadata`):
|
||||
```tsx
|
||||
<Composition
|
||||
id="Narrated"
|
||||
component={Narrated}
|
||||
calculateMetadata={calcNarratedMetadata}
|
||||
durationInFrames={300} // placeholder, replaced by calculateMetadata
|
||||
fps={30} width={1920} height={1080}
|
||||
defaultProps={{ title: "My story", voice: "voice.mp3", music: "music.mp3" }}
|
||||
/>
|
||||
```
|
||||
|
||||
Install: `npm i @remotion/media-utils`
|
||||
|
||||
Getting a voiceover
|
||||
- Record yourself, or generate speech with a text-to-speech service, and save as `public/voice.mp3`. Never commit API keys; keep them in environment variables.
|
||||
- Pair with `templates/captions.md`: feed it word timings from a transcription of the same file.
|
||||
|
||||
Rules
|
||||
- Music volume 0.1-0.2 so the voice stays clear.
|
||||
- `<Sequence from={...}>` delays when a sound starts.
|
||||
- Audio is only in the final render (mp4). Stills (`remotion still`) have no sound.
|
||||
- Check the sound made it in: `ffprobe out/video.mp4` should list an `Audio:` stream.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Template: Captions
|
||||
|
||||
Words appear in time with speech. Give each word a start time in seconds.
|
||||
|
||||
`src/Captions.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
type Word = { text: string; start: number; end: number }; // seconds
|
||||
|
||||
export const Captions = ({ words, background = "#111" }: { words: Word[]; background?: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const t = frame / fps;
|
||||
|
||||
// Show a window of up to 5 words around the current one.
|
||||
const current = words.findIndex((w) => t >= w.start && t < w.end);
|
||||
const i = current === -1 ? 0 : current;
|
||||
const visible = words.slice(Math.floor(i / 5) * 5, Math.floor(i / 5) * 5 + 5);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: background, justifyContent: "flex-end", alignItems: "center", paddingBottom: 200 }}>
|
||||
<div style={{ fontSize: 72, fontWeight: 800, color: "white", textShadow: "0 4px 12px black", textAlign: "center", maxWidth: "80%" }}>
|
||||
{visible.map((w, k) => (
|
||||
<span key={k} style={{ color: t >= w.start && t < w.end ? "#ffd400" : "white", marginRight: 16 }}>
|
||||
{w.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Tips
|
||||
- Get word times from a transcription tool (e.g. `@remotion/captions` or Whisper) and save them as JSON in `public/`.
|
||||
- `background` is the backdrop when there is no video. Pass `"transparent"` when layering over a video.
|
||||
- Layer it over the video: `<AbsoluteFill><OffthreadVideo src={staticFile("clip.mp4")} /><Captions words={words} background="transparent" /></AbsoluteFill>`.
|
||||
@@ -1,37 +0,0 @@
|
||||
# Template: Animated bar chart
|
||||
|
||||
Bars grow one after another.
|
||||
|
||||
`src/BarChart.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
type Bar = { label: string; value: number };
|
||||
|
||||
export const BarChart = ({ data, color = "#4f8cff" }: { data: Bar[]; color?: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const max = Math.max(...data.map((d) => d.value));
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0b1020", padding: 120, justifyContent: "flex-end" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 40, height: 700 }}>
|
||||
{data.map((d, i) => {
|
||||
const grow = spring({ frame: frame - i * 6, fps, config: { damping: 15 } });
|
||||
return (
|
||||
<div key={d.label} style={{ flex: 1, textAlign: "center", color: "white", fontSize: 36 }}>
|
||||
<div style={{ height: (d.value / max) * 600 * grow, background: color, borderRadius: 8 }} />
|
||||
<div style={{ marginTop: 16 }}>{d.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Tips
|
||||
- Delay each bar with `frame - i * 6` (6 frames apart).
|
||||
- For lines, animate `strokeDashoffset` on an SVG path using `interpolate`.
|
||||
- Keep numbers readable: show the value as text, rounded.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Template: Title intro / outro card
|
||||
|
||||
A short card (2-3 seconds) with a title and subtitle. Use at the start or end.
|
||||
|
||||
`src/TitleCard.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
export const TitleCard = ({ title, subtitle }: { title: string; subtitle?: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const rise = spring({ frame, fps, config: { damping: 14 } });
|
||||
const fadeOut = interpolate(frame, [durationInFrames - 15, durationInFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "linear-gradient(135deg,#1b2a6b,#0b1020)", justifyContent: "center", alignItems: "center", opacity: fadeOut }}>
|
||||
<h1 style={{ color: "white", fontSize: 130, margin: 0, transform: `translateY(${(1 - rise) * 60}px)`, opacity: rise }}>{title}</h1>
|
||||
{subtitle && <p style={{ color: "#9fb3ff", fontSize: 48, opacity: rise }}>{subtitle}</p>}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Chain with the main video using `<Series>`:
|
||||
```tsx
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={90}><TitleCard title="My Video" /></Series.Sequence>
|
||||
<Series.Sequence durationInFrames={600}><MainScene /></Series.Sequence>
|
||||
<Series.Sequence durationInFrames={90}><TitleCard title="Thanks for watching" /></Series.Sequence>
|
||||
</Series>
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
# Template: Vertical clips (Reels / TikTok / Shorts)
|
||||
|
||||
Same code, different size. Register a vertical composition:
|
||||
|
||||
```tsx
|
||||
<Composition
|
||||
id="Vertical"
|
||||
component={MyScene}
|
||||
width={1080}
|
||||
height={1920}
|
||||
fps={30}
|
||||
durationInFrames={30 * 30} // 30 seconds
|
||||
/>
|
||||
```
|
||||
|
||||
Render: `npx remotion render Vertical out/vertical.mp4`
|
||||
|
||||
Rules
|
||||
- Keep text inside the **safe zone**: about 150px from the top, 350px from the bottom (apps cover those parts with buttons and captions), and 60px from the sides.
|
||||
- Use big text (60px+). Phone screens are small.
|
||||
- Keep clips under 60 seconds (Shorts limit); 15-30 seconds does best.
|
||||
- Use `useVideoConfig()` for width/height instead of hard-coding, so the same scene works wide and tall.
|
||||
- Put captions in the lower-middle, above the bottom safe zone (see captions.md).
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,46 @@
|
||||
# PRD: Keith Rana Portfolio (built from the "Jack 3D Creator" template)
|
||||
|
||||
## The restaurant analogy
|
||||
| Stage | Restaurant | This project |
|
||||
|---|---|---|
|
||||
| Phase 1 (done) | Food stand: one great dish, open today | The full single-page site with Keith's real content, photos and animations |
|
||||
| Phase 2 | Small restaurant: a few tables, a real menu | Make it easy to edit (content in one file), better mobile + accessibility |
|
||||
| Phase 3 | Full restaurant: front of house + back of house | Real contact form, email, project pages, CMS, analytics |
|
||||
|
||||
Front of house = what visitors see. Back of house = forms, email, storage, security.
|
||||
|
||||
## Core features
|
||||
1. Hero: giant "Hi, i'm jack" title, nav, magnetic portrait, Contact button
|
||||
2. Marquee: two rows of GIFs sliding left/right as you scroll
|
||||
3. About: floating 3D icons + words that light up as you scroll
|
||||
4. Services: white panel, 5 numbered services
|
||||
5. Projects: 3 cards that stack and shrink as you scroll
|
||||
6. Reusable parts: ContactButton, LiveProjectButton, FadeIn, Magnet, AnimatedText
|
||||
|
||||
## Phased roadmap
|
||||
**Phase 1 — Food stand (today).** Static site, all 5 sections, all animations, responsive.
|
||||
Done when: builds with no errors; page loads with no console errors; every button/link works; all image URLs load.
|
||||
|
||||
**Phase 2 — Small restaurant.** Nav links smooth-scroll to sections, Contact Me opens a mailto, content moved into editable data files, reduced-motion support, alt text, SEO tags, image fallbacks.
|
||||
|
||||
**Phase 3 — Full restaurant.** Contact form with a backend (server-side validation, spam protection, rate limits), project detail pages, CMS, analytics, hosting.
|
||||
|
||||
## Test ladder (each step must pass before the next)
|
||||
1. Page loads with no errors
|
||||
2. Buttons / links / forms work
|
||||
3. Real external APIs/assets work (images load)
|
||||
4. Security check (no secrets in code, safe external links, dependency audit)
|
||||
|
||||
## Security notes
|
||||
Phase 1 has no forms, no logins, no secrets, so risk is low. External links use rel="noopener noreferrer". When Phase 3 adds a form, validation and spam protection must live on the server, and API keys must never be in front-end code.
|
||||
|
||||
## Phase 1 status (done)
|
||||
- Content comes from Keith's existing portfolio: profile, 8 capabilities, 3 roles + 5 earlier roles, 4 projects, 10 certifications, contact details (all in `src/data/content.ts`).
|
||||
- Each project card has its own illustrative diagram (drawn from the deliverables in Keith's portfolio text; swap for real screenshots when available).
|
||||
- Sections: Hero (portrait), scrolling photo/skills rows, About, Capability, Experience, Projects (stacking cards), Credentials, Contact.
|
||||
- Photos and the Kanit font ship inside the site (no outside image hosts needed).
|
||||
- Tested at 360, 390, 768, 1024, 1440 and 1920 px wide: no sideways scroll, no console errors, all images load.
|
||||
- Cloud preview: `node scripts/build-preview.mjs out.html` builds one self-contained page.
|
||||
|
||||
## Not used yet
|
||||
- `it_helpdesk.db` (87 IT help-desk FAQs) was supplied but is not portfolio content. Candidate for a Phase 2 FAQ section if wanted.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Keith Rana, IT Service Delivery Manager in Melbourne, Australia." />
|
||||
<title>Keith Rana -- IT Service Delivery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "keith-portfolio",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/kanit": "^5.3.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// Builds ONE self-contained HTML page for the Claude cloud preview.
|
||||
// Claude's cloud pages block outside images and files, so photos and fonts are baked in.
|
||||
// Usage: node scripts/build-preview.mjs <output.html>
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const out = process.argv[2] ?? 'preview.html';
|
||||
execFileSync('npm', ['run', 'build'], { stdio: 'inherit', env: { ...process.env, INLINE: '1' } });
|
||||
|
||||
const dir = 'dist/assets/';
|
||||
const files = readdirSync(dir);
|
||||
const js = readFileSync(dir + files.find((f) => f.endsWith('.js')), 'utf8').replace(/<\/script/g, '<\\/script');
|
||||
const css = readFileSync(dir + files.find((f) => f.endsWith('.css')), 'utf8');
|
||||
|
||||
const html = `<meta charset="utf-8">
|
||||
<title>Keith Rana Portfolio</title>
|
||||
<style>${css}
|
||||
html,body{background:#0C0C0C}</style>
|
||||
<div id="root"></div>
|
||||
<script type="module">${js}</script>
|
||||
`;
|
||||
writeFileSync(out, html);
|
||||
const mb = Buffer.byteLength(html) / 1048576;
|
||||
console.log(`wrote ${out} (${mb.toFixed(1)} MB)${mb > 16 ? ' WARNING: over the 16 MB page limit' : ''}`);
|
||||
@@ -0,0 +1,26 @@
|
||||
import HeroSection from './sections/HeroSection';
|
||||
import MarqueeSection from './sections/MarqueeSection';
|
||||
import AboutSection from './sections/AboutSection';
|
||||
import ServicesSection from './sections/ServicesSection';
|
||||
import ExperienceSection from './sections/ExperienceSection';
|
||||
import ProjectsSection from './sections/ProjectsSection';
|
||||
import CredentialsSection from './sections/CredentialsSection';
|
||||
import ContactSection from './sections/ContactSection';
|
||||
import { ContactProvider } from './components/ContactDialog';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ContactProvider>
|
||||
<main style={{ background: '#0C0C0C', overflowX: 'clip' }}>
|
||||
<HeroSection />
|
||||
<MarqueeSection />
|
||||
<AboutSection />
|
||||
<ServicesSection />
|
||||
<ExperienceSection />
|
||||
<ProjectsSection />
|
||||
<CredentialsSection />
|
||||
<ContactSection />
|
||||
</main>
|
||||
</ContactProvider>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 641 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 277 KiB |
@@ -0,0 +1,30 @@
|
||||
import { useRef } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import type { MotionValue } from 'framer-motion';
|
||||
|
||||
function Char({ char, index, total, progress }: { char: string; index: number; total: number; progress: MotionValue<number> }) {
|
||||
const start = index / total;
|
||||
const end = start + 1 / total;
|
||||
const opacity = useTransform(progress, [start, end], [0.2, 1]);
|
||||
return (
|
||||
<span className="relative">
|
||||
<span className="invisible">{char}</span>
|
||||
<motion.span className="absolute left-0 top-0" style={{ opacity }}>
|
||||
{char}
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnimatedText({ text, className, style }: { text: string; className?: string; style?: React.CSSProperties }) {
|
||||
const ref = useRef<HTMLParagraphElement>(null);
|
||||
const { scrollYProgress } = useScroll({ target: ref, offset: ['start 0.8', 'end 0.2'] });
|
||||
const chars = Array.from(text);
|
||||
return (
|
||||
<p ref={ref} className={className} style={style} aria-label={text}>
|
||||
{chars.map((c, i) =>
|
||||
c === ' ' ? ' ' : <Char key={i} char={c} index={i} total={chars.length} progress={scrollYProgress} />
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useOpenContact } from './ContactDialog';
|
||||
|
||||
export default function ContactButton() {
|
||||
const openContact = useOpenContact();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openContact}
|
||||
className="inline-block whitespace-nowrap rounded-full px-8 py-3 text-xs font-medium uppercase tracking-widest text-white sm:px-10 sm:py-3.5 sm:text-sm md:px-12 md:py-4 md:text-base"
|
||||
style={{
|
||||
background: 'linear-gradient(123deg, #18011F 7%, #B600A8 37%, #7621B0 72%, #BE4C00 100%)',
|
||||
boxShadow: '0px 4px 4px rgba(181, 1, 167, 0.25), 4px 4px 12px #7721B1 inset',
|
||||
outline: '2px solid white',
|
||||
outlineOffset: '-3px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Contact Me
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check, Copy, Mail, X } from 'lucide-react';
|
||||
import { profile } from '../data/content';
|
||||
|
||||
const OpenContext = createContext<() => void>(() => {});
|
||||
export const useOpenContact = () => useContext(OpenContext);
|
||||
|
||||
const subject = encodeURIComponent('Portfolio enquiry');
|
||||
const to = encodeURIComponent(profile.email);
|
||||
const options = [
|
||||
{ label: 'Open my email app', href: `mailto:${profile.email}?subject=${subject}`, newTab: false },
|
||||
{ label: 'Write in Gmail', href: `https://mail.google.com/mail/?view=cm&fs=1&to=${to}&su=${subject}`, newTab: true },
|
||||
{ label: 'Write in Yahoo Mail', href: `https://compose.mail.yahoo.com/?to=${to}&subject=${subject}`, newTab: true },
|
||||
];
|
||||
|
||||
const optionClass =
|
||||
'flex items-center justify-between gap-3 rounded-full border-2 border-[#D7E2EA] px-6 py-3 text-sm font-medium uppercase tracking-widest text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10 sm:text-base';
|
||||
|
||||
export function ContactProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
|
||||
const show = useCallback(() => {
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setCopied(false);
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const hide = useCallback(() => {
|
||||
setOpen(false);
|
||||
opener.current?.focus?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
closeRef.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && hide();
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, hide]);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(profile.email);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OpenContext.Provider value={show}>
|
||||
{children}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
onMouseDown={(e) => e.target === e.currentTarget && hide()}
|
||||
>
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="contact-title" className="relative flex w-full max-w-md flex-col gap-5 rounded-[32px] border-2 border-[#D7E2EA] bg-[#0C0C0C] p-6 sm:p-8">
|
||||
<button ref={closeRef} onClick={hide} aria-label="Close" className="absolute right-4 top-4 rounded-full p-2 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||
<X size={22} />
|
||||
</button>
|
||||
<h2 id="contact-title" className="hero-heading pr-10 text-4xl font-black uppercase leading-none sm:text-5xl">Email Keith</h2>
|
||||
<p className="font-light leading-relaxed text-[#D7E2EA]/80">Pick how you would like to write. Your message goes to the address below.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="contact-email"
|
||||
readOnly
|
||||
value={profile.email}
|
||||
aria-label="Email address"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="min-w-0 flex-1 rounded-full border border-[#D7E2EA]/40 bg-transparent px-5 py-3 text-[#D7E2EA]"
|
||||
/>
|
||||
<button onClick={copy} className="flex shrink-0 items-center gap-2 rounded-full border border-[#D7E2EA]/40 px-4 py-3 text-sm text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{options.map((o) => (
|
||||
<a key={o.label} href={o.href} className={optionClass} {...(o.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}>
|
||||
{o.label}
|
||||
<Mail size={18} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OpenContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import type { MotionProps } from 'framer-motion';
|
||||
import type { ComponentType, ElementType, ReactNode } from 'react';
|
||||
|
||||
type FadeInProps = {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
className?: string;
|
||||
as?: ElementType;
|
||||
};
|
||||
|
||||
const cache = new Map<ElementType, ComponentType<MotionProps & { className?: string }>>();
|
||||
const getMotion = (el: ElementType) => {
|
||||
if (!cache.has(el)) cache.set(el, motion.create(el as 'div') as ComponentType<MotionProps & { className?: string }>);
|
||||
return cache.get(el)!;
|
||||
};
|
||||
|
||||
export default function FadeIn({
|
||||
children,
|
||||
delay = 0,
|
||||
duration = 0.7,
|
||||
x = 0,
|
||||
y = 30,
|
||||
className,
|
||||
as = 'div',
|
||||
}: FadeInProps) {
|
||||
const Component = getMotion(as);
|
||||
return (
|
||||
<Component
|
||||
className={className}
|
||||
initial={{ opacity: 0, x, y }}
|
||||
whileInView={{ opacity: 1, x: 0, y: 0 }}
|
||||
viewport={{ once: true, margin: '50px', amount: 0 }}
|
||||
transition={{ duration, delay, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type MagnetProps = {
|
||||
children: ReactNode;
|
||||
padding?: number;
|
||||
strength?: number;
|
||||
activeTransition?: string;
|
||||
inactiveTransition?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Magnet({
|
||||
children,
|
||||
padding = 100,
|
||||
strength = 2,
|
||||
activeTransition = 'transform 0.3s ease-out',
|
||||
inactiveTransition = 'transform 0.6s ease-in-out',
|
||||
className,
|
||||
}: MagnetProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!ref.current) return;
|
||||
const { left, top, width, height } = ref.current.getBoundingClientRect();
|
||||
const cx = left + width / 2;
|
||||
const cy = top + height / 2;
|
||||
const dx = Math.abs(cx - e.clientX);
|
||||
const dy = Math.abs(cy - e.clientY);
|
||||
if (dx < width / 2 + padding && dy < height / 2 + padding) {
|
||||
setActive(true);
|
||||
setPos({ x: (e.clientX - cx) / strength, y: (e.clientY - cy) / strength });
|
||||
} else {
|
||||
setActive(false);
|
||||
setPos({ x: 0, y: 0 });
|
||||
}
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
return () => window.removeEventListener('mousemove', onMove);
|
||||
}, [padding, strength]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<div
|
||||
style={{
|
||||
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
|
||||
transition: active ? activeTransition : inactiveTransition,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
export type DiagramKind = 'network' | 'server' | 'sccm' | 'voice';
|
||||
|
||||
const INK = '#D7E2EA';
|
||||
const TEAL = '#2BB3A6';
|
||||
const MUTED = 'rgba(215,226,234,0.55)';
|
||||
const FILL = '#14161a';
|
||||
|
||||
function Box({ x, y, w, h, label, sub, accent }: { x: number; y: number; w: number; h: number; label: string; sub?: string; accent?: boolean }) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={w} height={h} rx={14} fill={FILL} stroke={accent ? TEAL : INK} strokeWidth={accent ? 2.5 : 1.5} />
|
||||
<text x={x + w / 2} y={y + h / 2 + (sub ? -4 : 8)} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>{label}</text>
|
||||
{sub && <text x={x + w / 2} y={y + h / 2 + 22} textAnchor="middle" fontSize={18} fill={MUTED}>{sub}</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Line({ x1, y1, x2, y2, dashed }: { x1: number; y1: number; x2: number; y2: number; dashed?: boolean }) {
|
||||
return <line x1={x1} y1={y1} x2={x2} y2={y2} stroke={MUTED} strokeWidth={2} strokeDasharray={dashed ? '6 6' : undefined} />;
|
||||
}
|
||||
|
||||
function Arrow({ x1, y, x2, label }: { x1: number; y: number; x2: number; label?: string }) {
|
||||
return (
|
||||
<g>
|
||||
<line x1={x1} y1={y} x2={x2 - 10} y2={y} stroke={TEAL} strokeWidth={3} />
|
||||
<path d={`M${x2 - 14} ${y - 8} L${x2} ${y} L${x2 - 14} ${y + 8} Z`} fill={TEAL} />
|
||||
{label && <text x={(x1 + x2) / 2} y={y - 14} textAnchor="middle" fontSize={20} fill={TEAL}>{label}</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ cx, cy, r = 14 }: { cx: number; cy: number; r?: number }) {
|
||||
return <circle cx={cx} cy={cy} r={r} fill={FILL} stroke={TEAL} strokeWidth={2.5} />;
|
||||
}
|
||||
|
||||
function Wifi({ cx, cy }: { cx: number; cy: number }) {
|
||||
return (
|
||||
<g fill="none" stroke={TEAL} strokeWidth={2.5} strokeLinecap="round">
|
||||
<path d={`M${cx - 18} ${cy - 4} Q${cx} ${cy - 22} ${cx + 18} ${cy - 4}`} />
|
||||
<path d={`M${cx - 10} ${cy + 4} Q${cx} ${cy - 6} ${cx + 10} ${cy + 4}`} />
|
||||
<circle cx={cx} cy={cy + 12} r={2.5} fill={TEAL} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const diagrams: Record<DiagramKind, { label: string; body: ReactNode }> = {
|
||||
network: {
|
||||
label: 'Network topology: Fortinet firewall connects to Fortinet 48P switches, which connect to access points.',
|
||||
body: (
|
||||
<>
|
||||
<Box x={190} y={30} w={220} h={64} label="Fortinet firewall" accent />
|
||||
<Line x1={300} y1={94} x2={300} y2={130} />
|
||||
<Line x1={150} y1={130} x2={450} y2={130} />
|
||||
<Line x1={150} y1={130} x2={150} y2={160} />
|
||||
<Line x1={450} y1={130} x2={450} y2={160} />
|
||||
<Box x={50} y={160} w={200} h={64} label="48P switch" sub="Fortinet" />
|
||||
<Box x={350} y={160} w={200} h={64} label="48P switch" sub="Fortinet" />
|
||||
{[90, 210, 390, 510].map((x, i) => (
|
||||
<g key={x}>
|
||||
<Line x1={i < 2 ? 150 : 450} y1={224} x2={x} y2={274} dashed />
|
||||
<Dot cx={x} cy={296} r={22} />
|
||||
<Wifi cx={x} cy={292} />
|
||||
</g>
|
||||
))}
|
||||
<text x={300} y={372} textAnchor="middle" fontSize={20} fill={MUTED}>Access point uplift</text>
|
||||
<text x={300} y={400} textAnchor="middle" fontSize={20} fill={MUTED}>Network topology upgrade</text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
server: {
|
||||
label: 'Server upgrade: virtual machines and services move from the existing server to the upgraded server, with updated network settings.',
|
||||
body: (
|
||||
<>
|
||||
<Box x={20} y={70} w={190} h={200} label="" />
|
||||
<text x={115} y={105} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>Existing server</text>
|
||||
<Box x={40} y={130} w={150} h={44} label="VMs" />
|
||||
<Box x={40} y={192} w={150} h={44} label="Services" />
|
||||
<Arrow x1={222} y={170} x2={378} label="Data migrated" />
|
||||
<Box x={390} y={70} w={190} h={200} label="" accent />
|
||||
<text x={485} y={105} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>Upgraded server</text>
|
||||
<Box x={410} y={130} w={150} h={44} label="VMs" />
|
||||
<Box x={410} y={192} w={150} h={44} label="Services" />
|
||||
<Line x1={485} y1={270} x2={485} y2={320} dashed />
|
||||
<Box x={150} y={320} w={320} h={56} label="Updated network settings" />
|
||||
<Line x1={470} y1={348} x2={485} y2={348} dashed />
|
||||
</>
|
||||
),
|
||||
},
|
||||
sccm: {
|
||||
label: 'SCCM implementation: install and configure SCCM, prepare settings, then establish the task-sequence process for endpoints.',
|
||||
body: (
|
||||
<>
|
||||
<Box x={200} y={26} w={200} h={64} label="SCCM" accent />
|
||||
<Line x1={300} y1={90} x2={300} y2={122} />
|
||||
<Box x={40} y={122} w={150} h={92} label="Install" sub="& configure" />
|
||||
<Box x={225} y={122} w={150} h={92} label="Prepare" sub="settings" />
|
||||
<Box x={410} y={122} w={150} h={92} label="Task" sub="sequence" accent />
|
||||
<Arrow x1={190} y={168} x2={225} />
|
||||
<Arrow x1={375} y={168} x2={410} />
|
||||
<Line x1={485} y1={214} x2={485} y2={262} dashed />
|
||||
<Line x1={150} y1={262} x2={450} y2={262} dashed />
|
||||
{[150, 250, 350, 450].map((x) => (
|
||||
<g key={x}>
|
||||
<Line x1={x} y1={262} x2={x} y2={292} dashed />
|
||||
<rect x={x - 30} y={292} width={60} height={40} rx={6} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
|
||||
<line x1={x - 40} y1={344} x2={x + 40} y2={344} stroke={TEAL} strokeWidth={3} strokeLinecap="round" />
|
||||
</g>
|
||||
))}
|
||||
<text x={300} y={392} textAnchor="middle" fontSize={20} fill={MUTED}>Endpoints built through the task sequence</text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
voice: {
|
||||
label: 'Microsoft Teams calling: number porting, dial-plan and call-flow design, then users supported by training, documentation and adoption monitoring.',
|
||||
body: (
|
||||
<>
|
||||
<Box x={20} y={50} w={160} h={70} label="Number" sub="porting" />
|
||||
<Box x={220} y={50} w={160} h={70} label="Dial plan" accent />
|
||||
<Box x={420} y={50} w={160} h={70} label="Call flow" />
|
||||
<Arrow x1={180} y={85} x2={220} />
|
||||
<Arrow x1={380} y={85} x2={420} />
|
||||
<Line x1={300} y1={120} x2={300} y2={168} dashed />
|
||||
<Line x1={100} y1={168} x2={500} y2={168} dashed />
|
||||
{[100, 300, 500].map((x) => (
|
||||
<g key={x}>
|
||||
<Line x1={x} y1={168} x2={x} y2={196} dashed />
|
||||
<circle cx={x} cy={214} r={16} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
|
||||
<path d={`M${x - 28} ${264} Q${x} ${226} ${x + 28} ${264}`} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
|
||||
</g>
|
||||
))}
|
||||
<text x={100} y={310} textAnchor="middle" fontSize={20} fill={INK}>User training</text>
|
||||
<text x={300} y={310} textAnchor="middle" fontSize={20} fill={INK}>Documentation</text>
|
||||
<text x={500} y={310} textAnchor="middle" fontSize={20} fill={INK}>Adoption monitoring</text>
|
||||
<text x={300} y={380} textAnchor="middle" fontSize={20} fill={MUTED}>Microsoft Teams calling</text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export default function ProjectDiagram({ kind, className, style }: { kind: DiagramKind; className?: string; style?: CSSProperties }) {
|
||||
const d = diagrams[kind];
|
||||
return (
|
||||
<svg viewBox="0 0 600 420" role="img" aria-label={`Illustrative diagram. ${d.label}`} className={className} style={{ fontFamily: 'Kanit, sans-serif', ...style }}>
|
||||
{d.body}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import portrait from '../assets/portrait.jpg';
|
||||
import leadership from '../assets/leadership.webp';
|
||||
import systems from '../assets/systems.webp';
|
||||
import signalMark from '../assets/signal-mark.webp';
|
||||
import networkPhoto from '../assets/projects/network.jpg';
|
||||
import serverPhoto from '../assets/projects/server.jpg';
|
||||
import sccmPhoto from '../assets/projects/sccm.jpg';
|
||||
import voicePhoto from '../assets/projects/voice.jpg';
|
||||
|
||||
import type { DiagramKind } from '../components/ProjectDiagram';
|
||||
|
||||
export const images = { portrait, leadership, systems, signalMark };
|
||||
|
||||
export const profile = {
|
||||
name: 'Keith Rana',
|
||||
firstName: 'keith',
|
||||
role: 'IT Service Delivery Manager',
|
||||
location: 'Melbourne, Australia',
|
||||
email: 'keith_rana@yahoo.com',
|
||||
phone: '04 1668 7558',
|
||||
phoneHref: 'tel:+61416687558',
|
||||
tagline: 'an it service delivery manager keeping critical services steady while the work keeps moving',
|
||||
};
|
||||
|
||||
export const aboutText =
|
||||
'A results-driven IT professional with over 16 years of experience across telecommunications, education, and managed services, including more than 8 years leading technical teams. I make technology service delivery visible, stable, and continually better. Let\'s make delivery more dependable.';
|
||||
|
||||
export const stats = [
|
||||
{ value: '16+', label: 'Years in IT' },
|
||||
{ value: '8+', label: 'Years leading' },
|
||||
];
|
||||
|
||||
export const expertise = [
|
||||
{ name: 'IT Service Delivery', description: 'End-to-end accountability for service delivery against agreed SLAs, with reporting to senior stakeholders.' },
|
||||
{ name: 'Enterprise Infrastructure', description: 'Enterprise network, server and endpoint environments, including Fortinet, Zscaler and SCCM.' },
|
||||
{ name: 'ITIL & Change Control', description: 'ITIL process, measured change and service reporting that improve compliance and service quality.' },
|
||||
{ name: 'Incident & Escalation Leadership', description: 'Major incident management, workload prioritisation and escalations led with a calm, evidence-based approach.' },
|
||||
{ name: 'Vendor & Stakeholder Management', description: 'Clear ownership of the vendor relationships that sit behind every service the business relies on.' },
|
||||
{ name: 'Microsoft 365 & Intune', description: 'Hands-on depth in Microsoft 365, Azure and Intune across cloud applications and end-user devices.' },
|
||||
{ name: 'Cybersecurity & Access', description: 'Security, identity and access practices built into daily operations, backed by Microsoft security certifications.' },
|
||||
{ name: 'Team Development', description: 'Coaching, performance conversations, training and mentoring that grow capable, confident support teams.' },
|
||||
];
|
||||
|
||||
export const roles = [
|
||||
{
|
||||
dates: 'Oct 2023 — Present',
|
||||
role: 'IT Service Delivery Manager',
|
||||
organisation: 'AFL Telecommunications',
|
||||
summary: 'Leading the daily delivery of business-critical IT services, balancing prompt resolution, availability, security, and a dependable experience for end users.',
|
||||
points: [
|
||||
'Own service operations across cloud applications, Windows and Linux environments, desktops, virtual apps, networks, and end-user support.',
|
||||
'Lead a support and service-desk team through coaching, performance conversations, workload prioritisation, and escalations.',
|
||||
'Use incident patterns, service-performance reporting, change controls, and feedback loops to improve ITIL compliance and service quality.',
|
||||
],
|
||||
},
|
||||
{
|
||||
dates: 'Dec 2021 — Sep 2023',
|
||||
role: 'Information Technology Manager',
|
||||
organisation: 'Suzanne Cory High School',
|
||||
summary: 'Managed technology services, documentation, knowledge systems, infrastructure capacity, and Microsoft 365 operations in a fast-moving education environment.',
|
||||
points: [
|
||||
'Oversaw Microsoft 365 services, endpoint operations, security, quality, and the administration and maintenance of core IT infrastructure.',
|
||||
'Led a skilled IT specialist team with a focus on strategic direction, mentoring, service effectiveness, and superior customer outcomes.',
|
||||
'Coordinated systems migrations, upgrades, and deployments to defined scope and schedule constraints.',
|
||||
],
|
||||
},
|
||||
{
|
||||
dates: 'May 2018 — Nov 2021',
|
||||
role: 'IT Support Specialist — Team Lead',
|
||||
organisation: 'Wesley College Melbourne',
|
||||
summary: 'Directed a six-person IT support team and helped translate organisational priorities into reliable, well-documented technical service.',
|
||||
points: [
|
||||
'Established project scope, supported IT initiatives, and aligned delivery activity with strategic objectives.',
|
||||
'Worked across Casper Management, AirWatch MDM, Microsoft Intune, and operational processes informed by ITIL principles.',
|
||||
'Identified skills gaps, organised training, coordinated with project managers, and maintained clear project documentation.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const foundations = [
|
||||
{ role: 'IT Support Officer', organisation: 'Mentone Grammar School', dates: 'Apr 2017 — Dec 2017' },
|
||||
{ role: 'IT Project Manager', organisation: 'Archirayan Infotech', dates: 'Aug 2015 — Jul 2016' },
|
||||
{ role: 'IT Technical Support', organisation: 'Has Tech Solutions', dates: 'Aug 2014 — Jul 2015' },
|
||||
{ role: 'IT Technical Engineer', organisation: 'Hindustan Cyber Services', dates: 'Oct 2012 — Aug 2014' },
|
||||
{ role: 'Assistant Systems Analyst', organisation: 'Prutha Technologies', dates: 'Oct 2009 — Sep 2010' },
|
||||
];
|
||||
|
||||
export const projects = [
|
||||
{
|
||||
category: 'Network',
|
||||
name: 'Network project',
|
||||
body: 'Fortinet Firewall upgrade, Fortinet 48P Switches upgrade, Fortinet access point uplift, and network topology upgrade.',
|
||||
delivered: ['Fortinet firewall upgrade', 'Fortinet 48P switches upgrade', 'Fortinet access point uplift', 'Network topology upgrade'],
|
||||
diagram: 'network' as DiagramKind,
|
||||
photo: networkPhoto,
|
||||
photoPosition: 'center',
|
||||
},
|
||||
{
|
||||
category: 'Infrastructure',
|
||||
name: 'Server upgrade',
|
||||
body: 'Upgraded the server with existing VMs and services, migrated data, and configured updated network settings.',
|
||||
delivered: ['Server upgraded with existing VMs and services', 'Data migrated', 'Updated network settings configured'],
|
||||
diagram: 'server' as DiagramKind,
|
||||
photo: serverPhoto,
|
||||
photoPosition: 'center',
|
||||
},
|
||||
{
|
||||
category: 'Endpoint',
|
||||
name: 'SCCM implementation',
|
||||
body: 'Installed and configured SCCM, prepared the required settings, and established the task-sequence process.',
|
||||
delivered: ['SCCM installed and configured', 'Required settings prepared', 'Task-sequence process established'],
|
||||
diagram: 'sccm' as DiagramKind,
|
||||
photo: sccmPhoto,
|
||||
photoPosition: 'center',
|
||||
},
|
||||
{
|
||||
category: 'Voice',
|
||||
name: 'Microsoft Teams calling',
|
||||
body: 'Delivered number-porting support, call-flow and dial-plan design, user training, documentation, and adoption monitoring.',
|
||||
delivered: ['Number-porting support', 'Call-flow and dial-plan design', 'User training and documentation', 'Adoption monitoring'],
|
||||
diagram: 'voice' as DiagramKind,
|
||||
photo: voicePhoto,
|
||||
photoPosition: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
export const certifications = [
|
||||
'Introduction to Cyber Security',
|
||||
'Windows Client',
|
||||
'Microsoft Teams Voice Engineer',
|
||||
'M365 Teams Administrator Associate',
|
||||
'Microsoft Azure Administrator Associate',
|
||||
'Microsoft Azure Security Engineer Associate',
|
||||
'M365 Security Administrator Associate',
|
||||
'Microsoft Information Protection Administrator Associate',
|
||||
'Project Management Essentials Certified',
|
||||
'Cloud Computing',
|
||||
];
|
||||
|
||||
export const education = { degree: 'Bachelor of Information Technology', school: 'Federation University', year: '2009' };
|
||||
@@ -0,0 +1,25 @@
|
||||
@import '@fontsource/kanit/latin-300.css';
|
||||
@import '@fontsource/kanit/latin-400.css';
|
||||
@import '@fontsource/kanit/latin-500.css';
|
||||
@import '@fontsource/kanit/latin-700.css';
|
||||
@import '@fontsource/kanit/latin-900.css';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body, #root { background: #0C0C0C; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { font-family: 'Kanit', sans-serif; }
|
||||
|
||||
.hero-heading {
|
||||
background: linear-gradient(180deg, #646973 0%, #BBCCD7 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import AnimatedText from '../components/AnimatedText';
|
||||
import ContactButton from '../components/ContactButton';
|
||||
import { aboutText, images, stats } from '../data/content';
|
||||
|
||||
export default function AboutSection() {
|
||||
return (
|
||||
<section id="about" className="relative flex min-h-screen flex-col items-center justify-center gap-16 px-5 py-20 sm:gap-20 sm:px-8 md:gap-24 md:px-10">
|
||||
<FadeIn delay={0.1} x={-80} y={0} duration={0.9} className="absolute left-[1%] top-[3%] w-[90px] sm:left-[2%] sm:w-[130px] md:left-[4%] md:w-[170px]">
|
||||
<img src={images.signalMark} alt="" className="aspect-square w-full rounded-2xl object-cover" />
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.25} x={-80} y={0} duration={0.9} className="absolute bottom-[4%] left-[3%] w-[100px] sm:left-[6%] sm:w-[150px] md:left-[10%] md:w-[200px]">
|
||||
<img src={images.systems} alt="" className="aspect-[4/3] w-full -rotate-6 rounded-2xl object-cover" />
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.15} x={80} y={0} duration={0.9} className="absolute right-[1%] top-[3%] w-[100px] sm:right-[2%] sm:w-[150px] md:right-[4%] md:w-[210px]">
|
||||
<img src={images.leadership} alt="" className="aspect-[3/2] w-full rotate-6 rounded-2xl object-cover" />
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.3} x={80} y={0} duration={0.9} className="absolute bottom-[4%] right-[3%] sm:right-[6%] md:right-[10%]">
|
||||
<div className="flex gap-2 sm:gap-3">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="rounded-2xl border border-[#D7E2EA]/30 px-2.5 py-2 text-center sm:px-5 sm:py-3">
|
||||
<div className="hero-heading text-2xl font-black leading-none sm:text-5xl md:text-6xl">{s.value}</div>
|
||||
<div className="mt-1 whitespace-nowrap text-[9px] font-light uppercase tracking-wide text-[#D7E2EA] sm:text-xs">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center gap-10 sm:gap-14 md:gap-16">
|
||||
<FadeIn delay={0} y={40}>
|
||||
<h2 className="hero-heading text-center font-black uppercase leading-none tracking-tight" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
About me
|
||||
</h2>
|
||||
</FadeIn>
|
||||
<AnimatedText
|
||||
text={aboutText}
|
||||
className="max-w-[560px] text-center font-medium leading-relaxed text-[#D7E2EA]"
|
||||
style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10 mb-24 sm:mb-28 md:mb-0">
|
||||
<ContactButton />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ArrowUpRight, Phone } from 'lucide-react';
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import ContactButton from '../components/ContactButton';
|
||||
import { profile } from '../data/content';
|
||||
|
||||
export default function ContactSection() {
|
||||
return (
|
||||
<section id="contact" className="flex flex-col items-center gap-10 px-5 pb-10 pt-20 sm:gap-14 sm:px-8 md:px-10 md:pt-28">
|
||||
<FadeIn>
|
||||
<h2 className="hero-heading text-center font-black uppercase leading-none tracking-tight" style={{ fontSize: 'clamp(2.6rem, 10vw, 140px)' }}>
|
||||
Let’s talk
|
||||
</h2>
|
||||
</FadeIn>
|
||||
<FadeIn>
|
||||
<p className="max-w-[560px] text-center font-medium leading-relaxed text-[#D7E2EA]" style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}>
|
||||
Based in {profile.location}. Available for the right conversation.
|
||||
</p>
|
||||
</FadeIn>
|
||||
<FadeIn>
|
||||
<ContactButton />
|
||||
</FadeIn>
|
||||
<FadeIn className="w-full max-w-3xl">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<a href={`mailto:${profile.email}`} className="flex min-w-0 items-center justify-between gap-3 rounded-3xl border border-[#D7E2EA]/25 p-5 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-light uppercase tracking-widest opacity-70">Email</span>
|
||||
<strong className="block break-words font-medium">{profile.email}</strong>
|
||||
</span>
|
||||
<ArrowUpRight size={20} className="shrink-0" />
|
||||
</a>
|
||||
<a href={profile.phoneHref} className="flex min-w-0 items-center justify-between gap-3 rounded-3xl border border-[#D7E2EA]/25 p-5 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-light uppercase tracking-widest opacity-70">Phone</span>
|
||||
<strong className="block font-medium">{profile.phone}</strong>
|
||||
</span>
|
||||
<Phone size={18} className="shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<footer className="mt-10 flex w-full flex-wrap justify-between gap-2 border-t border-[#D7E2EA]/20 pt-6 text-xs font-light uppercase tracking-wider text-[#D7E2EA]/70">
|
||||
<span>{profile.name}</span>
|
||||
<span>{profile.role}</span>
|
||||
<span>{profile.location}</span>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import { certifications, education } from '../data/content';
|
||||
|
||||
export default function CredentialsSection() {
|
||||
return (
|
||||
<section id="credentials" className="px-5 py-20 sm:px-8 sm:py-24 md:px-10 md:py-32">
|
||||
<FadeIn>
|
||||
<h2 className="hero-heading mb-10 text-center font-black uppercase leading-none tracking-tight sm:mb-14" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
Credentials
|
||||
</h2>
|
||||
</FadeIn>
|
||||
<FadeIn>
|
||||
<div className="mx-auto mb-12 flex max-w-5xl flex-col items-center gap-1 text-center text-[#D7E2EA] sm:mb-16">
|
||||
<span className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">Education</span>
|
||||
<strong className="font-medium uppercase" style={{ fontSize: 'clamp(1.1rem, 2.2vw, 2.1rem)' }}>{education.degree}</strong>
|
||||
<span className="font-light opacity-70">{education.school}, {education.year}</span>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<div className="mx-auto grid max-w-5xl gap-3 sm:grid-cols-2">
|
||||
{certifications.map((c, i) => (
|
||||
<FadeIn key={c} delay={(i % 2) * 0.08}>
|
||||
<div className="flex h-full items-center gap-4 rounded-3xl border border-[#D7E2EA]/25 p-5 sm:p-6">
|
||||
<span className="hero-heading text-3xl font-black leading-none sm:text-4xl">{String(i + 1).padStart(2, '0')}</span>
|
||||
<p className="min-w-0 font-medium uppercase leading-snug text-[#D7E2EA]" style={{ fontSize: 'clamp(0.85rem, 1.4vw, 1.1rem)' }}>{c}</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import { foundations, roles } from '../data/content';
|
||||
|
||||
export default function ExperienceSection() {
|
||||
return (
|
||||
<section id="experience" className="bg-white px-5 pb-28 pt-20 sm:px-8 sm:pb-32 sm:pt-24 md:px-10 md:pb-40 md:pt-32">
|
||||
<FadeIn>
|
||||
<h2 className="mb-16 text-center font-black uppercase leading-none text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
Experience
|
||||
</h2>
|
||||
</FadeIn>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{roles.map((r, i) => (
|
||||
<FadeIn key={r.role + r.organisation}>
|
||||
<article className="grid gap-4 py-8 sm:py-10 md:grid-cols-[220px_1fr] md:gap-12 md:py-12" style={{ borderTop: '1px solid rgba(12, 12, 12, 0.15)' }}>
|
||||
<div className="flex flex-col gap-1 text-[#0C0C0C]">
|
||||
<span className="text-sm font-medium uppercase tracking-wider opacity-60">{r.dates}</span>
|
||||
<span className="font-black uppercase leading-tight" style={{ fontSize: 'clamp(1.1rem, 1.8vw, 1.5rem)' }}>{r.organisation}</span>
|
||||
<span className="text-sm font-light opacity-60">{String(i + 1).padStart(2, '0')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-4 text-[#0C0C0C]">
|
||||
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{r.role}</h3>
|
||||
<p className="max-w-2xl font-light leading-relaxed opacity-70" style={{ fontSize: 'clamp(0.9rem, 1.6vw, 1.2rem)' }}>{r.summary}</p>
|
||||
<ul className="flex max-w-2xl flex-col gap-3">
|
||||
{r.points.map((p) => (
|
||||
<li key={p} className="flex gap-3 font-light leading-relaxed" style={{ fontSize: 'clamp(0.85rem, 1.4vw, 1.05rem)' }}>
|
||||
<Check size={18} className="mt-1 shrink-0" />
|
||||
<span>{p}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</FadeIn>
|
||||
))}
|
||||
|
||||
<FadeIn>
|
||||
<div className="grid gap-6 py-10 md:grid-cols-[220px_1fr] md:gap-12 md:py-12" style={{ borderTop: '1px solid rgba(12, 12, 12, 0.15)' }}>
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-[#0C0C0C] opacity-60">Earlier foundations</h3>
|
||||
<div className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
{foundations.map((f) => (
|
||||
<div key={f.role + f.organisation} className="flex min-w-0 flex-col text-[#0C0C0C]">
|
||||
<span className="text-xs font-medium uppercase tracking-wider opacity-60">{f.dates}</span>
|
||||
<strong className="font-medium uppercase">{f.role}</strong>
|
||||
<span className="font-light opacity-70">{f.organisation}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import Magnet from '../components/Magnet';
|
||||
import ContactButton from '../components/ContactButton';
|
||||
import { images, profile } from '../data/content';
|
||||
|
||||
const links = [
|
||||
{ label: 'About', href: '#about' },
|
||||
{ label: 'Skills', href: '#skills' },
|
||||
{ label: 'Projects', href: '#projects' },
|
||||
{ label: 'Contact', href: '#contact' },
|
||||
];
|
||||
|
||||
export default function HeroSection() {
|
||||
return (
|
||||
<section className="relative flex h-screen min-h-[600px] flex-col" style={{ overflowX: 'clip' }}>
|
||||
<FadeIn delay={0} y={-20}>
|
||||
<nav className="flex justify-between px-6 pt-6 md:px-10 md:pt-8" aria-label="Section navigation">
|
||||
{links.map((l) => (
|
||||
<a
|
||||
key={l.label}
|
||||
href={l.href}
|
||||
className="text-sm font-medium uppercase tracking-wider text-[#D7E2EA] transition-opacity duration-200 hover:opacity-70 md:text-lg lg:text-[1.4rem]"
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</FadeIn>
|
||||
|
||||
<div className="overflow-hidden">
|
||||
<FadeIn delay={0.15} y={40}>
|
||||
<h1 className="hero-heading mt-6 w-full whitespace-nowrap text-center text-[16.5vw] font-black uppercase leading-none tracking-tight sm:mt-4 md:-mt-5">
|
||||
Hi, i’m {profile.firstName}
|
||||
</h1>
|
||||
</FadeIn>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 z-10 w-[min(280px,52vh)] -translate-x-1/2 -translate-y-1/2 sm:bottom-0 sm:top-auto sm:w-[min(360px,56vh)] sm:translate-y-0 md:w-[min(440px,58vh)] lg:w-[min(520px,58vh)]">
|
||||
<FadeIn delay={0.6} y={30}>
|
||||
<Magnet padding={150} strength={3} activeTransition="transform 0.3s ease-out" inactiveTransition="transform 0.6s ease-in-out" className="!block">
|
||||
<img
|
||||
src={images.portrait}
|
||||
alt="Keith Rana at his work desk"
|
||||
className="aspect-square w-full border border-[#D7E2EA]/30 object-cover object-top"
|
||||
style={{ borderRadius: '999px 999px 32px 32px' }}
|
||||
/>
|
||||
</Magnet>
|
||||
</FadeIn>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-end justify-between gap-4 px-6 pb-7 sm:pb-8 md:px-10 md:pb-10">
|
||||
<FadeIn delay={0.35} y={20}>
|
||||
<p
|
||||
className="max-w-[160px] font-light uppercase leading-snug tracking-wide text-[#D7E2EA] sm:max-w-[220px] md:max-w-[260px]"
|
||||
style={{ fontSize: 'clamp(0.7rem, 1.4vw, 1.5rem)' }}
|
||||
>
|
||||
{profile.tagline}
|
||||
</p>
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.5} y={20} className="relative z-20">
|
||||
<ContactButton />
|
||||
</FadeIn>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { expertise, images } from '../data/content';
|
||||
|
||||
const TILE_W = 420;
|
||||
const GAP = 12;
|
||||
|
||||
const photoTiles: { src: string; alt: string; position: string }[] = [
|
||||
{ src: images.portrait, alt: 'Keith Rana at his work desk', position: 'center 20%' },
|
||||
{ src: images.leadership, alt: 'Hands organising a service-delivery briefing', position: 'center' },
|
||||
{ src: images.systems, alt: 'Abstract composition of connected service pathways', position: 'center' },
|
||||
];
|
||||
|
||||
const photos = photoTiles.map((t) => (
|
||||
<img
|
||||
key={t.alt}
|
||||
src={t.src}
|
||||
alt={t.alt}
|
||||
loading="lazy"
|
||||
className="h-[270px] w-[420px] shrink-0 rounded-2xl object-cover"
|
||||
style={{ objectPosition: t.position }}
|
||||
/>
|
||||
));
|
||||
|
||||
const skills = expertise.map((e, i) => (
|
||||
<div
|
||||
key={e.name}
|
||||
className="flex h-[270px] w-[420px] shrink-0 flex-col justify-between rounded-2xl border border-[#D7E2EA]/25 p-6"
|
||||
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}
|
||||
>
|
||||
<span className="hero-heading text-5xl font-black leading-none">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span className="text-2xl font-medium uppercase leading-tight text-[#D7E2EA]">{e.name}</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
function Row({ tiles, setWidth, transform }: { tiles: ReactNode[]; setWidth: number; transform: string }) {
|
||||
return (
|
||||
<div className="flex" style={{ gap: GAP, marginLeft: -setWidth, transform, willChange: 'transform' }} aria-hidden={tiles === skills}>
|
||||
{[0, 1, 2].map((copy) => (
|
||||
<div key={copy} className="flex" style={{ gap: GAP }}>
|
||||
{tiles}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MarqueeSection() {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
if (!ref.current) return;
|
||||
const top = ref.current.getBoundingClientRect().top + window.scrollY;
|
||||
setOffset((window.scrollY - top + window.innerHeight) * 0.3);
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onScroll);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('resize', onScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const width = (n: number) => n * (TILE_W + GAP) + GAP;
|
||||
|
||||
return (
|
||||
<section ref={ref} className="flex flex-col gap-3 bg-[#0C0C0C] pb-10 pt-24 sm:pt-32 md:pt-40" style={{ overflow: 'clip' }}>
|
||||
<Row tiles={photos} setWidth={width(photos.length)} transform={`translateX(${offset - 200}px)`} />
|
||||
<Row tiles={skills} setWidth={width(skills.length)} transform={`translateX(${-(offset - 200)}px)`} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useRef } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { Check } from 'lucide-react';
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import ProjectDiagram from '../components/ProjectDiagram';
|
||||
import { projects } from '../data/content';
|
||||
|
||||
type Project = (typeof projects)[number];
|
||||
|
||||
const radius = 'rounded-[40px] sm:rounded-[50px] md:rounded-[60px]';
|
||||
|
||||
function ProjectCard({ project, index, total }: { project: Project; index: number; total: number }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({ target: ref, offset: ['start end', 'start start'] });
|
||||
const targetScale = 1 - (total - 1 - index) * 0.03;
|
||||
const scale = useTransform(scrollYProgress, [0, 1], [1, targetScale]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="sticky top-24 min-h-[85vh]">
|
||||
<motion.div
|
||||
className={`border-2 border-[#D7E2EA] bg-[#0C0C0C] p-4 sm:p-6 md:p-8 ${radius}`}
|
||||
style={{ scale, top: `${index * 28}px`, position: 'relative', transformOrigin: 'top center' }}
|
||||
>
|
||||
<div className="mb-4 flex flex-col gap-3 sm:mb-6 md:flex-row md:items-center md:justify-between md:gap-10">
|
||||
<div className="flex items-center gap-4 sm:gap-8">
|
||||
<span className="hero-heading font-black leading-none" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div className="min-w-0 text-[#D7E2EA]">
|
||||
<p className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">{project.category}</p>
|
||||
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{project.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="max-w-xl px-1 font-light leading-relaxed text-[#D7E2EA]/70" style={{ fontSize: 'clamp(0.85rem, 1.5vw, 1.15rem)' }}>
|
||||
{project.body}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
<div className="flex flex-col gap-3 sm:w-[40%] sm:gap-4">
|
||||
<img
|
||||
src={project.photo}
|
||||
alt={`${project.name}: illustrative photo`}
|
||||
loading="lazy"
|
||||
className={`w-full object-cover ${radius}`}
|
||||
style={{ height: 'clamp(150px, 16vw, 230px)', objectPosition: project.photoPosition }}
|
||||
/>
|
||||
<div
|
||||
className={`flex flex-1 flex-col justify-center gap-3 border border-[#D7E2EA]/25 p-5 sm:gap-4 sm:p-6 md:p-8 ${radius}`}
|
||||
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)', minHeight: 'clamp(160px, 22vw, 340px)' }}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-widest text-[#D7E2EA]/60 sm:text-sm">Delivered</span>
|
||||
<ul className="flex flex-col gap-2 sm:gap-3">
|
||||
{project.delivered.map((d) => (
|
||||
<li key={d} className="flex gap-2 font-light leading-snug text-[#D7E2EA]" style={{ fontSize: 'clamp(0.8rem, 1.3vw, 1.1rem)' }}>
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{d}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`order-first flex items-center justify-center border border-[#D7E2EA]/25 p-3 sm:order-none sm:w-[60%] sm:p-5 md:p-8 ${radius}`}
|
||||
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}
|
||||
>
|
||||
<ProjectDiagram kind={project.diagram} className="h-auto w-full" style={{ maxHeight: "clamp(240px, 30vw, 430px)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectsSection() {
|
||||
return (
|
||||
<section id="projects" className="relative z-10 -mt-10 rounded-t-[40px] bg-[#0C0C0C] px-5 py-20 sm:-mt-12 sm:rounded-t-[50px] sm:px-8 sm:py-24 md:-mt-14 md:rounded-t-[60px] md:px-10 md:py-32">
|
||||
<FadeIn>
|
||||
<h2 className="hero-heading mb-16 text-center font-black uppercase leading-none tracking-tight sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
Projects
|
||||
</h2>
|
||||
</FadeIn>
|
||||
{projects.map((p, i) => (
|
||||
<ProjectCard key={p.name} project={p} index={i} total={projects.length} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import { expertise } from '../data/content';
|
||||
|
||||
export default function ServicesSection() {
|
||||
return (
|
||||
<section id="skills" className="rounded-t-[40px] bg-white px-5 pt-20 sm:rounded-t-[50px] sm:px-8 sm:pt-24 md:rounded-t-[60px] md:px-10 md:pt-32">
|
||||
<h2 className="mb-16 text-center font-black uppercase text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
Capability
|
||||
</h2>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{expertise.map((s, i) => (
|
||||
<FadeIn key={s.name} delay={(i % 4) * 0.1}>
|
||||
<div
|
||||
className="flex items-start gap-4 py-8 sm:gap-8 sm:py-10 md:gap-12 md:py-12"
|
||||
style={{ borderTop: i === 0 ? undefined : '1px solid rgba(12, 12, 12, 0.15)' }}
|
||||
>
|
||||
<span className="min-w-[1.6em] font-black leading-none text-[#0C0C0C]" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-3 text-[#0C0C0C]">
|
||||
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{s.name}</h3>
|
||||
<p className="max-w-2xl font-light leading-relaxed opacity-60" style={{ fontSize: 'clamp(0.85rem, 1.6vw, 1.25rem)' }}>
|
||||
{s.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: { extend: { fontFamily: { sans: ['Kanit', 'sans-serif'] } } },
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// INLINE=1 bakes every image and font into the JS/CSS (used for the one-file cloud preview).
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
build: { assetsInlineLimit: process.env.INLINE ? 100_000_000 : 4096 },
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"motion-design": {
|
||||
"source": "LottieFiles/motion-design-skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/motion-design/SKILL.md",
|
||||
"computedHash": "9fcf9130cc46ee8fd1d8e09a9263347a9d76efbcbfa46591cc4cefaf19bbb453"
|
||||
}
|
||||
}
|
||||
}
|
||||