Create <OrbitImages /> animation

This commit is contained in:
Alfarish Fizikri
2026-02-21 13:33:01 +07:00
parent 54c1941143
commit 932767e855
5 changed files with 609 additions and 0 deletions

View File

@@ -78,6 +78,7 @@ export const CATEGORIES = [
'Star Border',
'Sticker Peel',
'Target Cursor',
'Orbit Images',
],
},
{

View File

@@ -26,6 +26,7 @@ const animations = {
'ghost-cursor': () => import('../demo/Animations/GhostCursorDemo.vue'),
'antigravity': () => import('../demo/Animations/AntigravityDemo.vue'),
'pixel-trail': () => import('../demo/Animations/PixelTrailDemo.vue'),
'orbit-images': () => import('../demo/Animations/OrbitImagesDemo.vue'),
};
const textAnimations = {

View File

@@ -0,0 +1,30 @@
import code from '@/content/Animations/OrbitImages/OrbitImages.vue?raw';
import { createCodeObject } from '@/types/code';
export const orbitImages = createCodeObject(code, 'Animations/OrbitImages', {
usage: `<template>
<OrbitImages
:images="images"
shape="ellipse"
:radius-x="340"
:radius-y="80"
:rotation="-8"
:duration="30"
:item-size="80"
:responsive="true"
/>
</template>
<script setup lang="ts">
import OrbitImages from './OrbitImages.vue';
const images = [
'https://picsum.photos/300/300?grayscale&random=1',
'https://picsum.photos/300/300?grayscale&random=2',
'https://picsum.photos/300/300?grayscale&random=3',
'https://picsum.photos/300/300?grayscale&random=4',
'https://picsum.photos/300/300?grayscale&random=5',
'https://picsum.photos/300/300?grayscale&random=6'
];
</script>`
});

View File

@@ -0,0 +1,377 @@
<script setup lang="ts">
// Component created by Dominik Koch
// https://x.com/dominikkoch
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
export type OrbitShape =
| 'ellipse'
| 'circle'
| 'square'
| 'rectangle'
| 'triangle'
| 'star'
| 'heart'
| 'infinity'
| 'wave'
| 'custom';
interface OrbitImagesProps {
images?: string[];
altPrefix?: string;
shape?: OrbitShape;
customPath?: string;
baseWidth?: number;
radiusX?: number;
radiusY?: number;
radius?: number;
starPoints?: number;
starInnerRatio?: number;
rotation?: number;
duration?: number;
itemSize?: number;
direction?: 'normal' | 'reverse';
fill?: boolean;
width?: number | '100%';
height?: number | 'auto';
className?: string;
showPath?: boolean;
pathColor?: string;
pathWidth?: number;
easing?: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
paused?: boolean;
responsive?: boolean;
}
const props = withDefaults(defineProps<OrbitImagesProps>(), {
images: () => [],
altPrefix: 'Orbiting image',
shape: 'ellipse',
customPath: undefined,
baseWidth: 1400,
radiusX: 700,
radiusY: 170,
radius: 300,
starPoints: 5,
starInnerRatio: 0.5,
rotation: -8,
duration: 40,
itemSize: 64,
direction: 'normal',
fill: true,
width: 100,
height: 100,
className: '',
showPath: false,
pathColor: 'rgba(0,0,0,0.1)',
pathWidth: 2,
easing: 'linear',
paused: false,
responsive: false
});
const containerRef = ref<HTMLDivElement | null>(null);
const scale = ref(1);
const progress = ref(0);
let animationFrameId: number | null = null;
let lastTime: number | null = null;
const designCenterX = computed(() => props.baseWidth / 2);
const designCenterY = computed(() => props.baseWidth / 2);
function generateEllipsePath(cx: number, cy: number, rx: number, ry: number): string {
return `M ${cx - rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy}`;
}
function generateCirclePath(cx: number, cy: number, r: number): string {
return generateEllipsePath(cx, cy, r, r);
}
function generateSquarePath(cx: number, cy: number, size: number): string {
const h = size / 2;
return `M ${cx - h} ${cy - h} L ${cx + h} ${cy - h} L ${cx + h} ${cy + h} L ${cx - h} ${cy + h} Z`;
}
function generateRectanglePath(cx: number, cy: number, w: number, h: number): string {
const hw = w / 2;
const hh = h / 2;
return `M ${cx - hw} ${cy - hh} L ${cx + hw} ${cy - hh} L ${cx + hw} ${cy + hh} L ${cx - hw} ${cy + hh} Z`;
}
function generateTrianglePath(cx: number, cy: number, size: number): string {
const height = (size * Math.sqrt(3)) / 2;
const hs = size / 2;
return `M ${cx} ${cy - height / 1.5} L ${cx + hs} ${cy + height / 3} L ${cx - hs} ${cy + height / 3} Z`;
}
function generateStarPath(cx: number, cy: number, outerR: number, innerR: number, points: number): string {
const step = Math.PI / points;
let path = '';
for (let i = 0; i < 2 * points; i++) {
const r = i % 2 === 0 ? outerR : innerR;
const angle = i * step - Math.PI / 2;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
path += i === 0 ? `M ${x} ${y}` : ` L ${x} ${y}`;
}
return path + ' Z';
}
function generateHeartPath(cx: number, cy: number, size: number): string {
const s = size / 30;
return `M ${cx} ${cy + 12 * s} C ${cx - 20 * s} ${cy - 5 * s}, ${cx - 12 * s} ${cy - 18 * s}, ${cx} ${cy - 8 * s} C ${cx + 12 * s} ${cy - 18 * s}, ${cx + 20 * s} ${cy - 5 * s}, ${cx} ${cy + 12 * s}`;
}
function generateInfinityPath(cx: number, cy: number, w: number, h: number): string {
const hw = w / 2;
const hh = h / 2;
return `M ${cx} ${cy} C ${cx + hw * 0.5} ${cy - hh}, ${cx + hw} ${cy - hh}, ${cx + hw} ${cy} C ${cx + hw} ${cy + hh}, ${cx + hw * 0.5} ${cy + hh}, ${cx} ${cy} C ${cx - hw * 0.5} ${cy + hh}, ${cx - hw} ${cy + hh}, ${cx - hw} ${cy} C ${cx - hw} ${cy - hh}, ${cx - hw * 0.5} ${cy - hh}, ${cx} ${cy}`;
}
function generateWavePath(cx: number, cy: number, w: number, amplitude: number, waves: number): string {
const pts: string[] = [];
const segs = waves * 20;
const hw = w / 2;
for (let i = 0; i <= segs; i++) {
const x = cx - hw + (w * i) / segs;
const y = cy + Math.sin((i / segs) * waves * 2 * Math.PI) * amplitude;
pts.push(i === 0 ? `M ${x} ${y}` : `L ${x} ${y}`);
}
for (let i = segs; i >= 0; i--) {
const x = cx - hw + (w * i) / segs;
const y = cy - Math.sin((i / segs) * waves * 2 * Math.PI) * amplitude;
pts.push(`L ${x} ${y}`);
}
return pts.join(' ') + ' Z';
}
const path = computed(() => {
const cx = designCenterX.value;
const cy = designCenterY.value;
switch (props.shape) {
case 'circle':
return generateCirclePath(cx, cy, props.radius);
case 'ellipse':
return generateEllipsePath(cx, cy, props.radiusX, props.radiusY);
case 'square':
return generateSquarePath(cx, cy, props.radius * 2);
case 'rectangle':
return generateRectanglePath(cx, cy, props.radiusX * 2, props.radiusY * 2);
case 'triangle':
return generateTrianglePath(cx, cy, props.radius * 2);
case 'star':
return generateStarPath(cx, cy, props.radius, props.radius * props.starInnerRatio, props.starPoints);
case 'heart':
return generateHeartPath(cx, cy, props.radius * 2);
case 'infinity':
return generateInfinityPath(cx, cy, props.radiusX * 2, props.radiusY * 2);
case 'wave':
return generateWavePath(cx, cy, props.radiusX * 2, props.radiusY, 3);
case 'custom':
return props.customPath || generateCirclePath(cx, cy, props.radius);
default:
return generateEllipsePath(cx, cy, props.radiusX, props.radiusY);
}
});
const containerWidth = computed(() => {
return props.responsive ? '100%' : (typeof props.width === 'number' ? `${props.width}px` : '100%');
});
const containerHeight = computed(() => {
return props.responsive ? 'auto' : (typeof props.height === 'number' ? `${props.height}px` : (typeof props.width === 'number' ? `${props.width}px` : 'auto'));
});
function updateScale() {
if (!props.responsive || !containerRef.value) return;
scale.value = containerRef.value.clientWidth / props.baseWidth;
}
function applyEasing(t: number): number {
switch (props.easing) {
case 'easeIn':
return t * t;
case 'easeOut':
return 1 - (1 - t) * (1 - t);
case 'easeInOut':
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
case 'linear':
default:
return t;
}
}
function animationLoop(currentTime: number) {
if (props.paused) {
lastTime = null;
animationFrameId = requestAnimationFrame(animationLoop);
return;
}
if (lastTime === null) {
lastTime = currentTime;
}
const deltaTime = (currentTime - lastTime) / 1000;
lastTime = currentTime;
const progressPerSecond = 100 / props.duration;
const delta = props.direction === 'reverse' ? -progressPerSecond * deltaTime : progressPerSecond * deltaTime;
progress.value = ((progress.value + delta) % 100 + 100) % 100;
animationFrameId = requestAnimationFrame(animationLoop);
}
function startAnimation() {
stopAnimation();
lastTime = null;
animationFrameId = requestAnimationFrame(animationLoop);
}
function stopAnimation() {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
}
function getOffsetDistance(index: number): string {
const itemOffset = props.fill ? (index / props.images.length) * 100 : 0;
const offset = (((progress.value + itemOffset) % 100) + 100) % 100;
return `${offset}%`;
}
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
updateScale();
startAnimation();
if (props.responsive && containerRef.value) {
resizeObserver = new ResizeObserver(updateScale);
resizeObserver.observe(containerRef.value);
}
});
onUnmounted(() => {
stopAnimation();
if (resizeObserver) {
resizeObserver.disconnect();
}
});
watch(
() => [props.duration, props.direction, props.paused],
() => {
if (!props.paused && animationFrameId === null) {
startAnimation();
}
}
);
watch(
() => props.responsive,
(newVal) => {
if (newVal && containerRef.value) {
updateScale();
if (!resizeObserver) {
resizeObserver = new ResizeObserver(updateScale);
resizeObserver.observe(containerRef.value);
}
} else if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
scale.value = 1;
}
}
);
const offsetDistances = computed(() => {
return props.images.map((_, index) => {
const itemOffset = props.fill ? (index / props.images.length) * 100 : 0;
const offset = (((progress.value + itemOffset) % 100) + 100) % 100;
return `${offset}%`;
});
});
</script>
<template>
<div
ref="containerRef"
:class="`relative mx-auto ${props.className}`"
:style="{
width: containerWidth,
height: containerHeight,
aspectRatio: props.responsive ? '1 / 1' : undefined
}"
aria-hidden="true"
>
<div
:class="props.responsive ? 'absolute left-1/2 top-1/2' : 'relative w-full h-full'"
:style="{
width: props.responsive ? `${props.baseWidth}px` : '100%',
height: props.responsive ? `${props.baseWidth}px` : '100%',
transform: props.responsive ? `translate(-50%, -50%) scale(${scale})` : undefined,
transformOrigin: 'center center'
}"
>
<div
class="relative w-full h-full"
:style="{
transform: `rotate(${props.rotation}deg)`,
transformOrigin: 'center center'
}"
>
<!-- SVG Path for debugging -->
<svg
v-if="props.showPath"
width="100%"
height="100%"
:viewBox="`0 0 ${props.baseWidth} ${props.baseWidth}`"
class="absolute inset-0 pointer-events-none"
>
<path
:d="path"
fill="none"
:stroke="props.pathColor"
:stroke-width="props.pathWidth / scale"
/>
</svg>
<!-- Orbit Items -->
<div
v-for="(src, index) in props.images"
:key="src"
class="absolute will-change-transform select-none"
:style="{
width: `${props.itemSize}px`,
height: `${props.itemSize}px`,
offsetPath: `path('${path}')`,
offsetRotate: '0deg',
offsetAnchor: 'center center',
offsetDistance: offsetDistances[index]
}"
>
<div :style="{ transform: `rotate(${-props.rotation}deg)` }">
<img
:src="src"
:alt="`${props.altPrefix} ${index + 1}`"
:draggable="false"
class="w-full h-full object-contain"
/>
</div>
</div>
</div>
</div>
<!-- Center Content Slot -->
<div
v-if="$slots.centerContent"
class="absolute inset-0 flex items-center justify-center z-10"
>
<slot name="centerContent" />
</div>
</div>
</template>

View File

@@ -0,0 +1,200 @@
<template>
<TabbedLayout>
<template #preview>
<div class="demo-container h-[400px] overflow-hidden">
<RefreshButton @click="forceRerender" />
<OrbitImages
:key="key"
:images="images"
:shape="shape"
:radius-x="radiusX"
:radius-y="radiusY"
:radius="radius"
:rotation="rotation"
:duration="duration"
:item-size="itemSize"
:direction="direction"
:fill="fill"
:show-path="showPath"
:paused="paused"
:responsive="true"
path-color="rgba(255,255,255,0.15)"
/>
</div>
<Customize>
<PreviewSelect
title="Shape"
v-model="shape"
:options="shapeOptions"
/>
<PreviewSelect
title="Direction"
v-model="direction"
:options="directionOptions"
/>
<PreviewSlider
title="Radius X"
v-model="radiusX"
:min="50"
:max="600"
:step="10"
value-unit="px"
/>
<PreviewSlider
title="Radius Y"
v-model="radiusY"
:min="50"
:max="600"
:step="10"
value-unit="px"
/>
<PreviewSlider
title="Radius"
v-model="radius"
:min="50"
:max="600"
:step="10"
value-unit="px"
/>
<PreviewSlider
title="Rotation"
v-model="rotation"
:min="-180"
:max="180"
:step="1"
value-unit="°"
/>
<PreviewSlider
title="Duration"
v-model="duration"
:min="5"
:max="120"
:step="5"
value-unit="s"
/>
<PreviewSlider
title="Item Size"
v-model="itemSize"
:min="20"
:max="120"
:step="4"
value-unit="px"
/>
<PreviewSwitch title="Fill (Distribute Evenly)" v-model="fill" />
<PreviewSwitch title="Show Path" v-model="showPath" />
<PreviewSwitch title="Paused" v-model="paused" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="[]" />
</template>
<template #code>
<CodeExample :code-object="orbitImages" />
</template>
<template #cli>
<CliInstallation :command="orbitImages.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import PropTable from '../../components/common/PropTable.vue';
import Dependencies from '../../components/code/Dependencies.vue';
import CliInstallation from '../../components/code/CliInstallation.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import PreviewSwitch from '../../components/common/PreviewSwitch.vue';
import PreviewSelect from '../../components/common/PreviewSelect.vue';
import RefreshButton from '../../components/common/RefreshButton.vue';
import OrbitImages from '../../content/Animations/OrbitImages/OrbitImages.vue';
import { orbitImages } from '@/constants/code/Animations/orbitImagesCode';
import { useForceRerender } from '@/composables/useForceRerender';
const { rerenderKey: key, forceRerender } = useForceRerender();
const images = [
'https://picsum.photos/300/300?grayscale&random=1',
'https://picsum.photos/300/300?grayscale&random=2',
'https://picsum.photos/300/300?grayscale&random=3',
'https://picsum.photos/300/300?grayscale&random=4',
'https://picsum.photos/300/300?grayscale&random=5',
'https://picsum.photos/300/300?grayscale&random=6'
];
const shape = ref<string>('ellipse');
const radiusX = ref(340);
const radiusY = ref(80);
const radius = ref(160);
const rotation = ref(-8);
const duration = ref(30);
const itemSize = ref(80);
const direction = ref<'normal' | 'reverse'>('normal');
const fill = ref(true);
const showPath = ref(true);
const paused = ref(false);
const shapeOptions = [
{ label: 'Ellipse', value: 'ellipse' },
{ label: 'Circle', value: 'circle' },
{ label: 'Square', value: 'square' },
{ label: 'Rectangle', value: 'rectangle' },
{ label: 'Triangle', value: 'triangle' },
{ label: 'Star', value: 'star' },
{ label: 'Heart', value: 'heart' },
{ label: 'Infinity', value: 'infinity' },
{ label: 'Wave', value: 'wave' }
];
const directionOptions = [
{ label: 'Normal', value: 'normal' },
{ label: 'Reverse', value: 'reverse' }
];
const propData = [
{ name: 'images', type: 'string[]', default: '[]', description: 'Array of image URLs to orbit along the path.' },
{ name: 'altPrefix', type: 'string', default: '"Orbiting image"', description: 'Prefix for auto-generated alt attributes.' },
{ name: 'shape', type: 'OrbitShape', default: '"ellipse"', description: 'Preset shape: ellipse, circle, square, rectangle, triangle, star, heart, infinity, wave, or custom.' },
{ name: 'customPath', type: 'string', default: 'undefined', description: 'Custom SVG path string (used when shape="custom").' },
{ name: 'baseWidth', type: 'number', default: '1400', description: 'Base width for the design coordinate space used for responsive scaling.' },
{ name: 'radiusX', type: 'number', default: '700', description: 'Horizontal radius for ellipse/rectangle shapes.' },
{ name: 'radiusY', type: 'number', default: '170', description: 'Vertical radius for ellipse/rectangle shapes.' },
{ name: 'radius', type: 'number', default: '300', description: 'Radius for circle, square, triangle, star, heart shapes.' },
{ name: 'starPoints', type: 'number', default: '5', description: 'Number of points for star shape.' },
{ name: 'starInnerRatio', type: 'number', default: '0.5', description: 'Inner radius ratio for star (0-1).' },
{ name: 'rotation', type: 'number', default: '-8', description: 'Rotation angle of the entire orbit path in degrees.' },
{ name: 'duration', type: 'number', default: '40', description: 'Duration of one complete orbit in seconds.' },
{ name: 'itemSize', type: 'number', default: '64', description: 'Width/height of each orbiting item in pixels.' },
{ name: 'direction', type: '"normal" | "reverse"', default: '"normal"', description: 'Animation direction.' },
{ name: 'fill', type: 'boolean', default: 'true', description: 'Whether to distribute items evenly around the orbit.' },
{ name: 'width', type: 'number | "100%"', default: '100', description: 'Container width in pixels or "100%".' },
{ name: 'height', type: 'number | "auto"', default: '100', description: 'Container height in pixels or "auto".' },
{ name: 'className', type: 'string', default: '""', description: 'Additional CSS class for the container.' },
{ name: 'showPath', type: 'boolean', default: 'false', description: 'Whether to show the orbit path for debugging.' },
{ name: 'pathColor', type: 'string', default: '"rgba(0,0,0,0.1)"', description: 'Stroke color when showPath is true.' },
{ name: 'pathWidth', type: 'number', default: '2', description: 'Stroke width when showPath is true.' },
{ name: 'easing', type: 'string', default: '"linear"', description: 'Animation easing: linear, easeIn, easeOut, easeInOut.' },
{ name: 'paused', type: 'boolean', default: 'false', description: 'Whether the animation is paused.' },
{ name: 'responsive', type: 'boolean', default: 'false', description: 'Enable responsive scaling based on container width.' }
];
watch([shape, direction], () => {
forceRerender();
});
</script>