Compare commits

..
Author SHA1 Message Date
Claude be5cbd8744 Add a relevant photo to each project card (network, server, SCCM, Teams calling)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
2026-10-02 03:29:02 +00:00
Claude 9f2186b9f8 Contact Me opens an email pop-up (mail app, Gmail, Yahoo, copy address)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
2026-10-02 03:25:26 +00:00
Claude c3def2c5c4 Add project-specific diagrams to each project card
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
2026-10-02 03:22:35 +00:00
Claude 55f7434bf2 Turn template into Keith Rana's portfolio with real content, photos and bundled font
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
2026-10-02 03:05:18 +00:00
Claude 1500ba1054 Add image-embedding preview builder, fix button wrapping on phones
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
2026-10-02 02:31:18 +00:00
Claude 6f3e3bc858 Add Jack 3D Creator portfolio template (Phase 1) with PRD
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
2026-10-02 02:09:03 +00:00
62 changed files with 3946 additions and 1936 deletions

No files matched your search

-315
View File
@@ -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
View File
@@ -1 +0,0 @@
../../.agents/skills/motion-design
-102
View File
@@ -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).
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+46
View File
@@ -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.
+13
View File
@@ -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>
+2754
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -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"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
};
+24
View File
@@ -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' : ''}`);
+26
View File
@@ -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>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

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>
);
}
+42
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+140
View File
@@ -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' };
+25
View File
@@ -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; }
}
+10
View File
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: { extend: { fontFamily: { sans: ['Kanit', 'sans-serif'] } } },
plugins: [],
};
+19
View File
@@ -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"]
}
+9
View File
@@ -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 },
});
-11
View File
@@ -1,11 +0,0 @@
{
"version": 1,
"skills": {
"motion-design": {
"source": "LottieFiles/motion-design-skill",
"sourceType": "github",
"skillPath": "skills/motion-design/SKILL.md",
"computedHash": "9fcf9130cc46ee8fd1d8e09a9263347a9d76efbcbfa46591cc4cefaf19bbb453"
}
}
}