Migrate <Ribbons />

This commit is contained in:
David Haz
2025-07-12 18:50:15 +03:00
parent 6a2fe32dc3
commit a6dd7a9792
7 changed files with 613 additions and 12 deletions

View File

@@ -32,6 +32,7 @@ export const CATEGORIES = [
'Animated Content',
'Fade Content',
'Pixel Transition',
'Ribbons',
'Glare Hover',
'Magnet Lines',
'Count Up',

View File

@@ -5,6 +5,7 @@ const animations = {
'glare-hover': () => import('../demo/Animations/GlareHoverDemo.vue'),
'magnet-lines': () => import('../demo/Animations/MagnetLinesDemo.vue'),
'click-spark': () => import('../demo/Animations/ClickSparkDemo.vue'),
'ribbons': () => import('../demo/Animations/RibbonsDemo.vue'),
'metallic-paint': () => import('../demo/Animations/MetallicPaintDemo.vue'),
'magnet': () => import('../demo/Animations/MagnetDemo.vue'),
'cubes': () => import('../demo/Animations/CubesDemo.vue'),

View File

@@ -0,0 +1,28 @@
import code from '@content/Animations/Ribbons/Ribbons.vue?raw';
import type { CodeObject } from '../../../types/code';
export const ribbons: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Animations/Ribbons`,
installation: `npm install ogl`,
usage: `<template>
<Ribbons
:colors="['#ff9346', '#7cff67', '#ffee51', '#5227FF']"
:base-spring="0.03"
:base-friction="0.9"
:base-thickness="30"
:offset-factor="0.05"
:max-age="500"
:point-count="50"
:speed-multiplier="0.6"
:enable-fade="false"
:enable-shader-effect="false"
:effect-amplitude="2"
:background-color="[0, 0, 0, 0]"
/>
</template>
<script setup lang="ts">
import Ribbons from "./Ribbons.vue";
</script>`,
code
};

View File

@@ -0,0 +1,355 @@
<template>
<div ref="ribbonsContainer" class="relative w-full h-full overflow-hidden" />
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { Renderer, Transform, Vec3, Color, Polyline } from 'ogl';
interface RibbonsProps {
colors?: string[];
baseSpring?: number;
baseFriction?: number;
baseThickness?: number;
offsetFactor?: number;
maxAge?: number;
pointCount?: number;
speedMultiplier?: number;
enableFade?: boolean;
enableShaderEffect?: boolean;
effectAmplitude?: number;
backgroundColor?: number[];
}
const props = withDefaults(defineProps<RibbonsProps>(), {
colors: () => ['#ff9346', '#7cff67', '#ffee51', '#5227FF'],
baseSpring: 0.03,
baseFriction: 0.9,
baseThickness: 30,
offsetFactor: 0.05,
maxAge: 500,
pointCount: 50,
speedMultiplier: 0.6,
enableFade: false,
enableShaderEffect: false,
effectAmplitude: 2,
backgroundColor: () => [0, 0, 0, 0]
});
const ribbonsContainer = ref<HTMLDivElement>();
let renderer: Renderer;
let scene: Transform;
let lines: {
spring: number;
friction: number;
mouseVelocity: Vec3;
mouseOffset: Vec3;
points: Vec3[];
polyline: Polyline;
}[] = [];
let frameId: number;
let lastTime = performance.now();
const mouse = new Vec3();
let resizeObserver: ResizeObserver | null = null;
const vertex = `
precision highp float;
attribute vec3 position;
attribute vec3 next;
attribute vec3 prev;
attribute vec2 uv;
attribute float side;
uniform vec2 uResolution;
uniform float uDPR;
uniform float uThickness;
uniform float uTime;
uniform float uEnableShaderEffect;
uniform float uEffectAmplitude;
varying vec2 vUV;
vec4 getPosition() {
vec4 current = vec4(position, 1.0);
vec2 aspect = vec2(uResolution.x / uResolution.y, 1.0);
vec2 nextScreen = next.xy * aspect;
vec2 prevScreen = prev.xy * aspect;
vec2 tangent = normalize(nextScreen - prevScreen);
vec2 normal = vec2(-tangent.y, tangent.x);
normal /= aspect;
normal *= mix(1.0, 0.1, pow(abs(uv.y - 0.5) * 2.0, 2.0));
float dist = length(nextScreen - prevScreen);
normal *= smoothstep(0.0, 0.02, dist);
float pixelWidthRatio = 1.0 / (uResolution.y / uDPR);
float pixelWidth = current.w * pixelWidthRatio;
normal *= pixelWidth * uThickness;
current.xy -= normal * side;
if(uEnableShaderEffect > 0.5) {
current.xy += normal * sin(uTime + current.x * 10.0) * uEffectAmplitude;
}
return current;
}
void main() {
vUV = uv;
gl_Position = getPosition();
}
`;
const fragment = `
precision highp float;
uniform vec3 uColor;
uniform float uOpacity;
uniform float uEnableFade;
varying vec2 vUV;
void main() {
float fadeFactor = 1.0;
if(uEnableFade > 0.5) {
fadeFactor = 1.0 - smoothstep(0.0, 1.0, vUV.y);
}
gl_FragColor = vec4(uColor, uOpacity * fadeFactor);
}
`;
const updateMouse = (e: MouseEvent | TouchEvent) => {
const container = ribbonsContainer.value;
if (!container) return;
let x: number, y: number;
const rect = container.getBoundingClientRect();
if ('changedTouches' in e && e.changedTouches.length) {
x = e.changedTouches[0].clientX - rect.left;
y = e.changedTouches[0].clientY - rect.top;
} else if (e instanceof MouseEvent) {
x = e.clientX - rect.left;
y = e.clientY - rect.top;
} else {
x = 0;
y = 0;
}
const width = container.clientWidth;
const height = container.clientHeight;
mouse.set((x / width) * 2 - 1, (y / height) * -2 + 1, 0);
};
const resize = () => {
const container = ribbonsContainer.value;
if (!container || !renderer) return;
const width = container.clientWidth;
const height = container.clientHeight;
renderer.setSize(width, height);
lines.forEach(line => line.polyline.resize());
};
const createLines = () => {
const center = (props.colors.length - 1) / 2;
lines = [];
props.colors.forEach((color, index) => {
const spring = props.baseSpring + (Math.random() - 0.5) * 0.05;
const friction = props.baseFriction + (Math.random() - 0.5) * 0.05;
const thickness = props.baseThickness + (Math.random() - 0.5) * 3;
const mouseOffset = new Vec3(
(index - center) * props.offsetFactor + (Math.random() - 0.5) * 0.01,
(Math.random() - 0.5) * 0.1,
0
);
const line = {
spring,
friction,
mouseVelocity: new Vec3(),
mouseOffset,
points: [] as Vec3[],
polyline: {} as Polyline
};
const count = props.pointCount;
const points: Vec3[] = [];
for (let i = 0; i < count; i++) {
points.push(new Vec3());
}
line.points = points;
line.polyline = new Polyline(renderer.gl, {
points,
vertex,
fragment,
uniforms: {
uColor: { value: new Color(color) },
uThickness: { value: thickness },
uOpacity: { value: 1.0 },
uTime: { value: 0.0 },
uEnableShaderEffect: { value: props.enableShaderEffect ? 1.0 : 0.0 },
uEffectAmplitude: { value: props.effectAmplitude },
uEnableFade: { value: props.enableFade ? 1.0 : 0.0 }
}
});
line.polyline.mesh.setParent(scene);
lines.push(line);
});
};
const update = () => {
frameId = requestAnimationFrame(update);
const currentTime = performance.now();
const dt = currentTime - lastTime;
lastTime = currentTime;
const tmp = new Vec3();
lines.forEach(line => {
tmp.copy(mouse).add(line.mouseOffset).sub(line.points[0]).multiply(line.spring);
line.mouseVelocity.add(tmp).multiply(line.friction);
line.points[0].add(line.mouseVelocity);
for (let i = 1; i < line.points.length; i++) {
if (isFinite(props.maxAge) && props.maxAge > 0) {
const segmentDelay = props.maxAge / (line.points.length - 1);
const alpha = Math.min(1, (dt * props.speedMultiplier) / segmentDelay);
line.points[i].lerp(line.points[i - 1], alpha);
} else {
line.points[i].lerp(line.points[i - 1], 0.9);
}
}
if (line.polyline.mesh.program.uniforms.uTime) {
line.polyline.mesh.program.uniforms.uTime.value = currentTime * 0.001;
}
line.polyline.updateGeometry();
});
renderer.render({ scene });
};
const initRibbons = () => {
const container = ribbonsContainer.value;
if (!container) return;
renderer = new Renderer({ dpr: window.devicePixelRatio || 2, alpha: true });
const gl = renderer.gl;
if (Array.isArray(props.backgroundColor) && props.backgroundColor.length === 4) {
gl.clearColor(
props.backgroundColor[0],
props.backgroundColor[1],
props.backgroundColor[2],
props.backgroundColor[3]
);
} else {
gl.clearColor(0, 0, 0, 0);
}
gl.canvas.style.position = 'absolute';
gl.canvas.style.top = '0';
gl.canvas.style.left = '0';
gl.canvas.style.width = '100%';
gl.canvas.style.height = '100%';
container.appendChild(gl.canvas);
scene = new Transform();
createLines();
container.addEventListener('mousemove', updateMouse);
container.addEventListener('touchstart', updateMouse);
container.addEventListener('touchmove', updateMouse);
resize();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
} else {
window.addEventListener('resize', resize);
}
update();
};
const cleanup = () => {
if (frameId) {
cancelAnimationFrame(frameId);
}
if (resizeObserver) {
resizeObserver.disconnect();
} else {
window.removeEventListener('resize', resize);
}
const container = ribbonsContainer.value;
if (container) {
container.removeEventListener('mousemove', updateMouse);
container.removeEventListener('touchstart', updateMouse);
container.removeEventListener('touchmove', updateMouse);
if (renderer && renderer.gl.canvas && renderer.gl.canvas.parentNode === container) {
container.removeChild(renderer.gl.canvas);
}
}
};
const recreateLines = () => {
lines.forEach(line => {
if (line.polyline.mesh && line.polyline.mesh.parent) {
line.polyline.mesh.setParent(null);
}
});
createLines();
};
watch(
() => [props.colors, props.pointCount],
() => {
if (renderer && scene) {
recreateLines();
}
},
{ deep: true }
);
watch(
() => [props.baseThickness, props.enableFade, props.enableShaderEffect, props.effectAmplitude, props.backgroundColor],
() => {
if (renderer && lines.length > 0) {
lines.forEach(line => {
if (line.polyline.mesh.program.uniforms.uEnableFade) {
line.polyline.mesh.program.uniforms.uEnableFade.value = props.enableFade ? 1.0 : 0.0;
}
if (line.polyline.mesh.program.uniforms.uEnableShaderEffect) {
line.polyline.mesh.program.uniforms.uEnableShaderEffect.value = props.enableShaderEffect ? 1.0 : 0.0;
}
if (line.polyline.mesh.program.uniforms.uEffectAmplitude) {
line.polyline.mesh.program.uniforms.uEffectAmplitude.value = props.effectAmplitude;
}
});
const gl = renderer.gl;
if (Array.isArray(props.backgroundColor) && props.backgroundColor.length === 4) {
gl.clearColor(
props.backgroundColor[0],
props.backgroundColor[1],
props.backgroundColor[2],
props.backgroundColor[3]
);
} else {
gl.clearColor(0, 0, 0, 0);
}
}
},
{ deep: true }
);
onMounted(() => {
initRibbons();
});
onUnmounted(() => {
cleanup();
});
</script>

View File

@@ -469,7 +469,7 @@ class CarLights {
const geometry = new THREE.TubeGeometry(curve, 40, 1, 8, false);
const instanced = new THREE.InstancedBufferGeometry();
// Copy geometry attributes
for (const key in geometry.attributes) {
instanced.setAttribute(key, geometry.attributes[key]);
}
@@ -634,7 +634,7 @@ class LightsSticks {
const options = this.options;
const geometry = new THREE.PlaneGeometry(1, 1);
const instanced = new THREE.InstancedBufferGeometry();
// Copy geometry attributes
for (const key in geometry.attributes) {
instanced.setAttribute(key, geometry.attributes[key]);
}
@@ -1038,17 +1038,14 @@ class App {
this.onMouseUp = this.onMouseUp.bind(this);
this.onWindowResize = this.onWindowResize.bind(this);
// Use ResizeObserver for better container tracking
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => {
this.onWindowResize();
});
resizeObserver.observe(container);
// Store reference for cleanup
this.resizeObserver = resizeObserver;
} else {
// Fallback to window resize
window.addEventListener('resize', this.onWindowResize);
}
}
@@ -1220,7 +1217,6 @@ class App {
tick() {
if (this.disposed || !this) return;
// Ensure renderer stays within container bounds
const containerWidth = this.container.offsetWidth;
const containerHeight = this.container.offsetHeight;

View File

@@ -40,7 +40,6 @@ const inlineStyles = computed(
);
const baseClasses = [
// Base styling
'text-white',
'font-black',
'whitespace-nowrap',
@@ -50,7 +49,6 @@ const baseClasses = [
'cursor-pointer',
'text-[clamp(2rem,10vw,8rem)]',
// Pseudo-elements base
'before:content-[attr(data-text)]',
'before:absolute',
'before:top-0',
@@ -69,19 +67,16 @@ const baseClasses = [
];
const normalGlitchClasses = [
// After pseudo-element for normal mode
'after:left-[10px]',
'after:[text-shadow:var(--after-shadow,-10px_0_red)]',
'after:[animation:animate-glitch_var(--after-duration,3s)_infinite_linear_alternate-reverse]',
// Before pseudo-element for normal mode
'before:left-[-10px]',
'before:[text-shadow:var(--before-shadow,10px_0_cyan)]',
'before:[animation:animate-glitch_var(--before-duration,2s)_infinite_linear_alternate-reverse]'
];
const hoverOnlyClasses = [
// Hide pseudo-elements by default
'before:content-[""]',
'before:opacity-0',
'before:[animation:none]',
@@ -89,7 +84,6 @@ const hoverOnlyClasses = [
'after:opacity-0',
'after:[animation:none]',
// Show and animate on hover
'hover:before:content-[attr(data-text)]',
'hover:before:opacity-100',
'hover:before:left-[-10px]',

View File

@@ -0,0 +1,226 @@
<template>
<div class="ribbons-demo">
<TabbedLayout>
<template #preview>
<div class="demo-container" style="height: 500px; position: relative">
<div class="hover-text">Hover Me.</div>
<Ribbons
:base-thickness="baseThickness"
:colors="colors"
:speed-multiplier="speedMultiplier"
:max-age="maxAge"
:enable-fade="enableFade"
:enable-shader-effect="enableWaves"
/>
</div>
<Customize>
<div class="count-controls">
<span class="count-label">Count</span>
<button @click="removeColor" :disabled="colors.length <= 1" class="count-button">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<span class="count-value">{{ colors.length }}</span>
<button @click="addColor" :disabled="colors.length >= 10" class="count-button">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<PreviewSlider title="Thickness" v-model="baseThickness" :min="1" :max="60" :step="1" />
<PreviewSlider title="Speed" v-model="speedMultiplier" :min="0.3" :max="0.7" :step="0.01" />
<PreviewSlider title="Max Age" v-model="maxAge" :min="300" :max="1000" :step="100" />
<PreviewSwitch title="Enable Fade" v-model="enableFade" />
<PreviewSwitch title="Enable Waves" v-model="enableWaves" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['ogl']" />
</template>
<template #code>
<CodeExample :code-object="ribbons" />
</template>
<template #cli>
<CliInstallation :command="ribbons.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import { ref } 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 Ribbons from '../../content/Animations/Ribbons/Ribbons.vue';
import { ribbons } from '@/constants/code/Animations/ribbonsCode';
const baseThickness = ref(30);
const colors = ref(['#5227FF']);
const speedMultiplier = ref(0.5);
const maxAge = ref(500);
const enableFade = ref(false);
const enableWaves = ref(false);
const addColor = () => {
if (colors.value.length < 10) {
const newColor = `#${Math.floor(Math.random() * 16777215)
.toString(16)
.padStart(6, '0')}`;
colors.value.push(newColor);
}
};
const removeColor = () => {
if (colors.value.length > 1) {
colors.value.pop();
}
};
const propData = [
{
name: 'colors',
type: 'string[]',
default: "['#5227FF']",
description: 'An array of color strings to be used for the ribbons.'
},
{
name: 'baseSpring',
type: 'number',
default: '0.03',
description: 'Base spring factor for the physics controlling ribbon motion.'
},
{
name: 'baseFriction',
type: 'number',
default: '0.9',
description: 'Base friction factor that dampens the ribbon motion.'
},
{
name: 'baseThickness',
type: 'number',
default: '30',
description: 'The base thickness of the ribbons.'
},
{
name: 'offsetFactor',
type: 'number',
default: '0.02',
description: 'A factor to horizontally offset the starting positions of the ribbons.'
},
{
name: 'maxAge',
type: 'number',
default: '500',
description: 'Delay in milliseconds controlling how long the ribbon trails extend.'
},
{
name: 'pointCount',
type: 'number',
default: '50',
description: 'The number of points that make up each ribbon.'
},
{
name: 'speedMultiplier',
type: 'number',
default: '0.5',
description: 'Multiplier that adjusts how fast trailing points interpolate towards the head.'
},
{
name: 'enableFade',
type: 'boolean',
default: 'true',
description: 'If true, a fade effect is applied along the length of the ribbon.'
},
{
name: 'enableShaderEffect',
type: 'boolean',
default: 'true',
description: 'If true, an additional sine-wave shader effect is applied to the ribbons.'
},
{
name: 'effectAmplitude',
type: 'number',
default: '2',
description: 'The amplitude of the shader displacement effect.'
},
{
name: 'backgroundColor',
type: 'number[]',
default: '[0, 0, 0, 0]',
description: 'An RGBA array specifying the clear color for the renderer.'
}
];
</script>
<style scoped>
.hover-text {
position: absolute;
font-size: clamp(2rem, 6vw, 6rem);
font-weight: 900;
color: #222;
z-index: 1;
pointer-events: none;
}
.demo-container {
padding: 0;
overflow: hidden;
}
.count-controls {
display: flex;
gap: 1rem;
align-items: center;
margin-top: 1rem;
}
.count-label {
font-size: 0.875rem;
}
.count-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
background: #1b1b1b;
border: 1px solid #333;
border-radius: 10px;
color: #fff;
cursor: pointer;
transition: background-color 0.2s;
}
.count-button:hover:not(:disabled) {
background: #222;
}
.count-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.count-value {
font-size: 0.875rem;
min-width: 1.5rem;
text-align: center;
}
</style>