Building my OS-style portfolio site with Vue 3 and GSAP

TL;DR
I built an OS-style portfolio with Vue 3 + GSAP and draggable windows.
- Key ideas: composables for state, GSAP timelines for open/close, and persisted windows via VueUse.
- Live demo here | Source here
Stack and versions (at time of writing)
- Node: 24.x
- Vite: 6.x
- Vue: 3.5.x
- TypeScript: 5.x
Quickstart
# clone my repo and run locally
git clone https://github.com/chenxing-dev/chenxing-dev.github.io
cd chenxing-dev.github.io
npm install
npm run dev
Discovering the World of Web Desktops
My journey began when I found this curated gallery of awesome web desktops. Projects like itisasifyouweredoingwork and Yahya J. Aifit’s portfolio take inspiration from traditional desktop environments, creating a nostalgic and engaging user experience.
The creativity and interactivity of these projects sparked my interest in building a similar OS-style portfolio site.
I was learning Vue 3 and wanted to explore its Composition API, so I decided to combine this with GSAP for animations to create a dynamic and visually appealing portfolio.
Technology Choices
Vue 3 Composition API: Learning Modern Reactivity
As someone new to Vue 3’s Composition API, I wanted to understand its reactivity system through a practical project.
Different from other frameworks, Vue’s Composition API uses ref
to create reactive state variables. The following example illustrates how I managed the desktop state:
// useDesktop.ts
import { ref } from 'vue'
import { useStorage } from '@vueuse/core'
export function useDesktop() {
// Persistent storage for open windows
const windows = ref(useStorage<WindowItem[]>("os-windows", []));
const zIndexCounter = ref(1)
const activeWindow = ref(null)
const openWindow = (app: AppItem) => {
const position = {
x: Math.random() * (window.innerWidth - (app.width || DEFAULT_WIDTH)),
y: Math.random() * (window.innerHeight - (app.height || DEFAULT_HEIGHT))
}
const newWindow = {
id: Date.now(),
app,
position: position,
zIndex: zIndexCounter++,
}
windows.value.push(newWindow)
activateWindow(newWindow.id)
}
return {
windows,
activeWindow,
openWindow
}
}
Understanding Vue’s Reactivity System
ref
creates a reactive wrapper around any value (primitive or object). Like React’suseState
, but you access the value via.value
.reactive
creates a deeply reactive object you mutate directly without.value
.computed
derives values that update when dependencies change.ted` function creates computed properties that automatically update when their dependencies change.
GSAP: Smooth Animations for Desktop Interactions
GSAP provided the animation capabilities needed to make window interactions feel natural. I focused on learning timeline controls and easing functions. With GSAP, I created opening and closing animations for windows:
// useWindowAnimations.ts
import { gsap } from 'gsap'
import { Ref } from 'vue'
export function useWindowAnimations(windowRef: Ref<HTMLElement | null>) {
const openAnimation = () => {
const el = windowRef.value;
if (!el) return;
// Ensure any ongoing animations are killed before starting a new one
gsap.killTweensOf(el);
return gsap.from(el, {
scale: 0.1,
opacity: 0,
duration: 0.3,
ease: "back.out(1.7)"
});
};
const closeAnimation = (): Promise<void> => {
const el = windowRef.value;
if (!el) return Promise.resolve();
// Ensure any ongoing animations are killed before starting a new one
gsap.killTweensOf(el);
return new Promise((resolve) => {
gsap.to(el, {
scale: 0.1,
opacity: 0,
duration: 0.3,
ease: "back.in(1.7)",
onComplete: () => resolve()
});
})
};
return {
openAnimation,
closeAnimation,
};
}
For the closing animation, I first tried the onBeforeUnmount
lifecycle hook, but the element was already being torn down.
Then I realized I needed to play the closing animation before actually removing the window (i.e., intercepting the close action).
So I called the closeAnimation
function from the close button handler, and only after the animation completes do I remove the window from the state.
vue-draggable-resizable: Draggable Window Implementation
Using vue-draggable-resizable
made windows draggable with minimal setup. Core wiring:
<!-- Window.vue -->
<script setup lang="ts">
import VueDraggableResizable from 'vue-draggable-resizable'
import "vue-draggable-resizable/style.css";
const { windowRef, position, contentComponent, handleClose, focus } =
useWindowInstance(props.window, emit)
</script>
<template>
<VueDraggableResizable
:x="isMobile ? 0 : position.x"
:y="isMobile ? 0 : position.y"
:w="width"
:h="height"
:draggable="!isMobile"
:resizable="false"
:drag-handle="'.drag-handle'"
@activated="focus"
>
<div ref="windowRef">
<div class="title-bar drag-handle">
<button class="close-btn" @click.stop="handleClose" />
</div>
<component :is="contentComponent" v-if="contentComponent" />
</div>
</VueDraggableResizable>
</template>
Project Architecture
Application Structure
I organized the project around composables and components:
src/
├── components/
│ ├── Window.vue
│ ├── WindowManager.vue
│ └── apps/
│ ├── AboutMe.vue
│ ├── ProjectsApp.vue
│ └── SettingsApp.vue
├── composables/
│ ├── useDesktop.ts
│ ├── useWindowAnimations.ts
│ └── useWindowInstance.ts
└── App.vue
What is a composable?
“Composables” are just reusable functions (typically starting with use
) that encapsulate specific reactive logic or behavior.
For example, useDesktop.ts
manages the state and behavior of the desktop environment, while useWindowAnimations.ts
handles the animation logic for opening and closing windows. This modular approach enhances code maintainability and readability.
Application Registry
A centralized app registry made adding new applications straightforward:
// apps-registry.ts
import { defineAsyncComponent } from 'vue'
export const APPS: AppConfig[] = [
{
id: "projects",
label: "Projects",
title: "Code Projects",
component: defineAsyncComponent(() => import("@/components/apps/ProjectsApp.vue")),
icon: defineAsyncComponent(() => import("@/icons/FluentEmojiFlatFileFolder.vue")),
width: 720,
height: 600,
showOnDesktop: true,
},
{
id: "terminal",
label: "Terminal",
title: "Terminal",
component: defineAsyncComponent(() => import("@/components/apps/Terminal.vue")),
icon: defineAsyncComponent(() => import("@/icons/SimpleIconsGnometerminal.vue")),
showOnDesktop: true,
},
]
Desktop Component Implementation
The Desktop
component manages the overall layout, including the wallpaper, desktop icons, and the window manager.
<!-- Desktop.vue -->
<template>
<div class="desktop">
<!-- Desktop background and icons -->
<Wallpaper />
<div v-memo="{ availableApps }" class="icons-grid">
<DesktopIcon
v-for="app in availableApps"
:key="app.id" :id="app.id"
:label="app.label"
@open="openWindow" />
</div>
<!-- Window Manager -->
<WindowManager :windows="windows" @close="closeWindow" @focus="focusWindow" />
</div>
</template>
<script setup lang="ts">
import { useDesktop } from '../composables/useDesktop'
import { getDesktopApps } from "@/config/apps-registry";
import Window from './Window.vue'
const { windows, openWindow, closeWindow, focusWindow } = useDesktop();
const availableApps = markRaw(getDesktopApps());
</script>
The WindowManager
component renders all open windows, passing down necessary props and event handlers.
Performance Considerations
Efficient Reactivity
Use markRaw
or shallowRef
for large objects/trees that don’t need deep reactivity. Here, the icons grid uses markRaw
to avoid unnecessary re-renders:
import { markRaw } from "vue";
// Make the apps list non-reactive
// as it doesn't change during runtime
// and to avoid unnecessary re-renders
const availableApps = markRaw(getDesktopApps());
<!-- Memorize icons grid: it won't update unless availableApps ref changes -->
<div v-memo="{ availableApps }" class="icons-grid">
<DesktopIcon
v-for="app in availableApps"
:key="app.id" :id="app.id"
:label="app.label"
@open="openWindow" />
</div>
Challenges Encountered
Window positioning and viewport safety
When opening new windows, I wanted them at random positions, but small displays can cause windows to overflow off-screen.
To address this, I calculated safe boundaries based on the window size and viewport dimensions:
const position = {
x: Math.random() * (window.innerWidth - (app.width || DEFAULT_WIDTH)),
y: Math.random() * (window.innerHeight - (app.height || DEFAULT_HEIGHT))
}
Considering mobile responsiveness, I disabled dragging on smaller screens to ensure usability.
Persisted state pitfalls
Using useStorage
from @vueuse/core
to persist open windows was convenient, but I had to clean up invalid states when the stored data is outdated (e.g., when the architecture changed or apps were removed from the registry). I sanitized the stored windows on load:
// useDesktop.ts
import { clamp } from '@/utils/number'
import type { AppItem, StoredWindow, WindowItem } from '@/types'
// Persistent storage for open windows
const windows = useStorage<WindowItem[]>("os-windows", []);
// Convert persisted window to full window object
export const sanitizeAndRehydrate = (stored: StoredWindow[] | unknown): WindowItem[] => {
const list = Array.isArray(stored) ? stored : [];
// Validate app exists
if (!item.app || !item.app.id) {
return null; // Outdated or invalid entry
}
const app = getAppById(item.app.id);
if (!app) {
return null; // App not found
}
return list.map(item => {
const app = getAppById(item.app.id);
if (!app) {
return null;
}
// Clamp position to viewport
const position = {
x: clamp(item.position.x, 0, window.innerWidth - (app.width || DEFAULT_WIDTH)),
y: clamp(item.position.y, 0, window.innerHeight - (app.height || DEFAULT_HEIGHT))
};
return {
...item,
app: {
id: app.id,
title: app.title,
icon: app.icon,
size: {
width: app.width || DEFAULT_WIDTH,
height: app.height || DEFAULT_HEIGHT
},
mobileSize: app.mobileSize,
},
position
};
}).filter((w): w is WindowItem => w !== null);
}
Keeping the persisted state in sync with the app registry was crucial to avoid broken windows.
Lessons Learned
Vue 3 Composition API Insights
- Compared to React hooks, Refs felt more straightforward for simple state.
- Single-file components improved organization with scoped styles.
- I’m continuing to deepen my understanding of reactivity trade-offs.
GSAP Thoughts
- Easing and timelines made animations feel polished.
- The learning curve was pretty steep. Only basic animations were implemented.
- I struggled with reactivity integration but learned to trigger animations in lifecycle hooks
Final Implementation
The completed portfolio features a clean desktop interface where visitors can open different applications showcasing my work. Each window is draggable with smooth opening/closing animations, and the state persists across sessions.
I hope this deep dive into my development process inspires you to explore building interactive web experiences using Vue 3 and GSAP!
Live demo: chenxing-dev.github.io | Source code: GitHub Repository