Merge pull request #55 from Utkarsh-Singhal-26/feat/sticker-peel

Added <StickerPeel /> animation
This commit is contained in:
David
2025-07-22 21:50:30 +03:00
committed by GitHub
5 changed files with 578 additions and 1 deletions

View File

@@ -1,5 +1,5 @@
// Highlighted sidebar items
export const NEW = ['Target Cursor', 'Ripple Grid', 'Magic Bento', 'Galaxy', 'Text Type', 'Glass Surface'];
export const NEW = ['Target Cursor', 'Ripple Grid', 'Magic Bento', 'Galaxy', 'Text Type', 'Glass Surface', 'Sticker Peel'];
export const UPDATED = [];
// Used for main sidebar navigation
@@ -39,6 +39,7 @@ export const CATEGORIES = [
'Splash Cursor',
'Pixel Transition',
'Target Cursor',
'Sticker Peel',
'Ribbons',
'Glare Hover',
'Magnet Lines',

View File

@@ -19,6 +19,7 @@ const animations = {
'shape-blur': () => import('../demo/Animations/ShapeBlurDemo.vue'),
'target-cursor': () => import('../demo/Animations/TargetCursorDemo.vue'),
'crosshair': () => import('../demo/Animations/CrosshairDemo.vue'),
'sticker-peel': () => import('../demo/Animations/StickerPeelDemo.vue'),
};
const textAnimations = {

View File

@@ -0,0 +1,23 @@
import code from '@/content/Animations/StickerPeel/StickerPeel.vue?raw';
import { createCodeObject } from '@/types/code';
export const stickerPeel = createCodeObject(code, 'Animations/StickerPeel', {
installation: `npm install gsap`,
usage: `<template>
<StickerPeel
:image-src="logo"
:width="200"
:rotate="30"
:peelBackHoverPct="20"
:peelBackActivePct="40"
:shadow-intensity="0.6"
:lighting-intensity="0.1"
:initial-position="{ x: -100, y: 100 }"
/>
</template>
<script setup lang="ts">
import StickerPeel from './StickerPeel.vue';
import logo from './assets/sticker.png';
</script>`
});

View File

@@ -0,0 +1,401 @@
<script setup lang="ts">
import { gsap } from 'gsap';
import { Draggable } from 'gsap/Draggable';
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
gsap.registerPlugin(Draggable);
interface StickerPeelProps {
imageSrc: string;
rotate?: number;
peelBackHoverPct?: number;
peelBackActivePct?: number;
peelEasing?: string;
peelHoverEasing?: string;
width?: number;
shadowIntensity?: number;
lightingIntensity?: number;
initialPosition?: 'center' | 'random' | { x: number; y: number };
peelDirection?: number;
className?: string;
}
const props = withDefaults(defineProps<StickerPeelProps>(), {
rotate: 30,
peelBackHoverPct: 30,
peelBackActivePct: 40,
peelEasing: 'power3.out',
peelHoverEasing: 'power2.out',
width: 200,
shadowIntensity: 0.6,
lightingIntensity: 0.1,
initialPosition: 'center',
peelDirection: 0,
className: ''
});
const containerRef = useTemplateRef('containerRef');
const dragTargetRef = useTemplateRef('dragTargetRef');
const pointLightRef = useTemplateRef('pointLightRef');
const pointLightFlippedRef = useTemplateRef('pointLightFlippedRef');
const draggableInstanceRef = ref<Draggable | null>(null);
const defaultPadding = 12;
let cleanup: (() => void) | null = null;
const setup = () => {
const target = dragTargetRef.value;
if (!target) return;
const boundsEl = target.parentNode as HTMLElement;
const draggable = Draggable.create(target, {
type: 'x,y',
bounds: boundsEl,
inertia: true,
onDrag(this: Draggable) {
const rot = gsap.utils.clamp(-24, 24, this.deltaX * 0.4);
gsap.to(target, { rotation: rot, duration: 0.15, ease: 'power1.out' });
},
onDragEnd() {
const rotationEase = 'power2.out';
const duration = 0.8;
gsap.to(target, { rotation: 0, duration, ease: rotationEase });
}
});
draggableInstanceRef.value = draggable[0];
const handleResize = () => {
if (draggableInstanceRef.value) {
draggableInstanceRef.value.update();
const currentX = gsap.getProperty(target, 'x') as number;
const currentY = gsap.getProperty(target, 'y') as number;
const boundsRect = boundsEl.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const maxX = boundsRect.width - targetRect.width;
const maxY = boundsRect.height - targetRect.height;
const newX = Math.max(0, Math.min(currentX, maxX));
const newY = Math.max(0, Math.min(currentY, maxY));
if (newX !== currentX || newY !== currentY) {
gsap.to(target, {
x: newX,
y: newY,
duration: 0.3,
ease: 'power2.out'
});
}
}
};
window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', handleResize);
const container = containerRef.value;
if (!container) return;
const handleTouchStart = () => {
container.classList.add('touch-active');
};
const handleTouchEnd = () => {
container.classList.remove('touch-active');
};
container.addEventListener('touchstart', handleTouchStart);
container.addEventListener('touchend', handleTouchEnd);
container.addEventListener('touchcancel', handleTouchEnd);
cleanup = () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('orientationchange', handleResize);
if (draggableInstanceRef.value) {
draggableInstanceRef.value.kill();
}
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchend', handleTouchEnd);
container.removeEventListener('touchcancel', handleTouchEnd);
};
};
onMounted(() => {
setup();
});
watch(
() => props.initialPosition,
() => {
const target = dragTargetRef.value;
if (!target) return;
let startX = 0,
startY = 0;
if (props.initialPosition === 'center') {
return;
}
if (
typeof props.initialPosition === 'object' &&
props.initialPosition.x !== undefined &&
props.initialPosition.y !== undefined
) {
startX = props.initialPosition.x;
startY = props.initialPosition.y;
}
gsap.set(target, { x: startX, y: startY });
},
{ immediate: true }
);
let lightHandler: (() => void) | null = null;
watch(
() => props.peelDirection,
() => {
const updateLight = (e: Event) => {
const mouseEvent = e as MouseEvent;
const rect = containerRef.value?.getBoundingClientRect();
if (!rect) return;
const x = mouseEvent.clientX - rect.left;
const y = mouseEvent.clientY - rect.top;
if (pointLightRef.value) {
gsap.set(pointLightRef.value, { attr: { x, y } });
}
const normalizedAngle = Math.abs(props.peelDirection % 360);
if (pointLightFlippedRef.value) {
if (normalizedAngle !== 180) {
gsap.set(pointLightFlippedRef.value, {
attr: { x, y: rect.height - y }
});
} else {
gsap.set(pointLightFlippedRef.value, {
attr: { x: -1000, y: -1000 }
});
}
}
};
const container = containerRef.value;
const eventType = 'mousemove';
if (container) {
container.addEventListener(eventType, updateLight);
lightHandler = () => container.removeEventListener(eventType, updateLight);
}
},
{ immediate: true }
);
onBeforeUnmount(() => {
const container = containerRef.value;
if (container && lightHandler) {
lightHandler();
lightHandler = null;
}
if (cleanup) {
cleanup();
cleanup = null;
}
});
const cssVars = computed(() => ({
'--sticker-rotate': `${props.rotate}deg`,
'--sticker-p': `${defaultPadding}px`,
'--sticker-peelback-hover': `${props.peelBackHoverPct}%`,
'--sticker-peelback-active': `${props.peelBackActivePct}%`,
'--sticker-peel-easing': props.peelEasing,
'--sticker-peel-hover-easing': props.peelHoverEasing,
'--sticker-width': `${props.width}px`,
'--sticker-shadow-opacity': props.shadowIntensity,
'--sticker-lighting-constant': props.lightingIntensity,
'--peel-direction': `${props.peelDirection}deg`,
'--sticker-start': `calc(-1 * ${defaultPadding}px)`,
'--sticker-end': `calc(100% + ${defaultPadding}px)`
}));
const stickerMainStyle = computed(() => ({
clipPath: `polygon(var(--sticker-start) var(--sticker-start), var(--sticker-end) var(--sticker-start), var(--sticker-end) var(--sticker-end), var(--sticker-start) var(--sticker-end))`,
transition: 'clip-path 0.6s ease-out',
filter: 'url(#dropShadow)',
willChange: 'clip-path, transform'
}));
const flapStyle = computed(() => ({
clipPath: `polygon(var(--sticker-start) var(--sticker-start), var(--sticker-end) var(--sticker-start), var(--sticker-end) var(--sticker-start), var(--sticker-start) var(--sticker-start))`,
top: `calc(-100% - var(--sticker-p) - var(--sticker-p))`,
transform: 'scaleY(-1)',
transition: 'all 0.6s ease-out',
willChange: 'clip-path, transform'
}));
const imageStyle = computed(() => ({
transform: `rotate(calc(${props.rotate}deg - ${props.peelDirection}deg))`,
width: `${props.width}px`
}));
const shadowImageStyle = computed(() => ({
...imageStyle.value,
filter: 'url(#expandAndFill)'
}));
const dropShadowStdDeviation = computed(() => 3 * props.shadowIntensity);
const flippedLightingConstant = computed(() => props.lightingIntensity * 7);
</script>
<template>
<div
ref="dragTargetRef"
:class="[`absolute cursor-grab active:cursor-grabbing transform-gpu ${className}`]"
:style="cssVars"
>
<svg width="0" height="0">
<defs>
<filter id="pointLight">
<feGaussianBlur stdDeviation="1" result="blur" />
<feSpecularLighting
result="spec"
in="blur"
:specularExponent="100"
:specularConstant="props.lightingIntensity"
lighting-color="white"
>
<fePointLight ref="pointLightRef" :x="100" :y="100" :z="300" />
</feSpecularLighting>
<feComposite in="spec" in2="SourceGraphic" result="lit" />
<feComposite in="lit" in2="SourceAlpha" operator="in" />
</filter>
<filter id="pointLightFlipped">
<feGaussianBlur stdDeviation="10" result="blur" />
<feSpecularLighting
result="spec"
in="blur"
:specularExponent="100"
:specularConstant="flippedLightingConstant"
lighting-color="white"
>
<fePointLight ref="pointLightFlippedRef" :x="100" :y="100" :z="300" />
</feSpecularLighting>
<feComposite in="spec" in2="SourceGraphic" result="lit" />
<feComposite in="lit" in2="SourceAlpha" operator="in" />
</filter>
<filter id="dropShadow">
<feDropShadow
dx="2"
dy="4"
:stdDeviation="dropShadowStdDeviation"
flood-color="black"
:flood-opacity="props.shadowIntensity"
/>
</filter>
<filter id="expandAndFill">
<feOffset dx="0" dy="0" in="SourceAlpha" result="shape" />
<feFlood flood-color="rgb(179,179,179)" result="flood" />
<feComposite operator="in" in="flood" in2="shape" />
</filter>
</defs>
</svg>
<div
class="relative touch-none sm:touch-auto select-none sticker-container"
ref="containerRef"
:style="{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
WebkitTapHighlightColor: 'transparent',
transform: `rotate(${peelDirection}deg)`,
transformOrigin: 'center'
}"
>
<div class="sticker-main" :style="stickerMainStyle">
<div :style="{ filter: 'url(#pointLight)' }">
<img :src="props.imageSrc" alt="" class="block" :style="imageStyle" draggable="false" @contextmenu.prevent />
</div>
<div class="top-4 left-2 absolute opacity-40 w-full h-full" :style="{ filter: 'brightness(0) blur(8px)' }">
<div class="sticker-flap" :style="flapStyle">
<img
:src="props.imageSrc"
alt=""
class="block"
:style="shadowImageStyle"
draggable="false"
@contextmenu.prevent
/>
</div>
</div>
<div class="left-0 absolute w-full h-full sticker-flap" :style="flapStyle">
<div :style="{ filter: 'url(#pointLightFlipped)' }">
<img
:src="props.imageSrc"
alt=""
class="block"
:style="shadowImageStyle"
draggable="false"
@contextmenu.prevent
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.sticker-container:hover .sticker-main,
.sticker-container.touch-active .sticker-main {
clip-path: polygon(
var(--sticker-start) var(--sticker-peelback-hover),
var(--sticker-end) var(--sticker-peelback-hover),
var(--sticker-end) var(--sticker-end),
var(--sticker-start) var(--sticker-end)
) !important;
}
.sticker-container:hover .sticker-flap,
.sticker-container.touch-active .sticker-flap {
clip-path: polygon(
var(--sticker-start) var(--sticker-start),
var(--sticker-end) var(--sticker-start),
var(--sticker-end) var(--sticker-peelback-hover),
var(--sticker-start) var(--sticker-peelback-hover)
) !important;
top: calc(-100% + 2 * var(--sticker-peelback-hover) - 1px) !important;
}
.sticker-container:active .sticker-main {
clip-path: polygon(
var(--sticker-start) var(--sticker-peelback-active),
var(--sticker-end) var(--sticker-peelback-active),
var(--sticker-end) var(--sticker-end),
var(--sticker-start) var(--sticker-end)
) !important;
}
.sticker-container:active .sticker-flap {
clip-path: polygon(
var(--sticker-start) var(--sticker-start),
var(--sticker-end) var(--sticker-start),
var(--sticker-end) var(--sticker-peelback-active),
var(--sticker-start) var(--sticker-peelback-active)
) !important;
top: calc(-100% + 2 * var(--sticker-peelback-active) - 1px) !important;
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<TabbedLayout>
<template #preview>
<div
class="flex-col bg-[linear-gradient(to_bottom,_#060010,_#0D0716,_#0D0716,_#060010)] h-[500px] overflow-hidden demo-container"
>
<StickerPeel
:image-src="logo"
:rotate="rotate"
:width="width"
:peel-back-hover-pct="peelBackHoverPct"
:peel-back-active-pct="peelBackActivePct"
:lighting-intensity="lightingIntensity"
:shadow-intensity="shadowIntensity"
:peel-direction="peelDirection"
class-name="z-2 absolute"
/>
<p
class="top-[1em] left-1/2 z-0 absolute font-black text-[#222] text-[clamp(1.5rem,4vw,3rem)] -translate-x-1/2 transform"
>
Try dragging it!
</p>
</div>
<Customize>
<PreviewSlider title="Peel Direction" :min="0" :max="360" :step="1" value-unit="°" v-model="peelDirection" />
<PreviewSlider title="Rotate" :min="0" :max="60" :step="1" value-unit="°" v-model="rotate" />
<PreviewSlider title="Width" :min="100" :max="300" :step="10" value-unit="px" v-model="width" />
<PreviewSlider title="Peel Hover %" :min="0" :max="50" :step="1" value-unit="%" v-model="peelBackHoverPct" />
<PreviewSlider title="Peel Active %" :min="0" :max="70" :step="1" value-unit="%" v-model="peelBackActivePct" />
<PreviewSlider title="Lighting Intensity" :min="0" :max="0.5" :step="0.01" v-model="lightingIntensity" />
<PreviewSlider title="Shadow Intensity" :min="0" :max="1" :step="0.01" v-model="shadowIntensity" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['gsap']" />
</template>
<template #code>
<CodeExample :code-object="stickerPeel" />
</template>
<template #cli>
<CliInstallation :command="stickerPeel.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import CliInstallation from '../../components/code/CliInstallation.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import Dependencies from '../../components/code/Dependencies.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import PropTable from '../../components/common/PropTable.vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import { stickerPeel } from '../../constants/code/Animations/stickerPeelCode';
import StickerPeel from '../../content/Animations/StickerPeel/StickerPeel.vue';
import logo from '../../assets/logos/vue-bits-sticker.png';
const rotate = ref(0);
const width = ref(200);
const peelBackHoverPct = ref(30);
const peelBackActivePct = ref(40);
const lightingIntensity = ref(0.1);
const shadowIntensity = ref(0.5);
const peelDirection = ref(0);
const propData = [
{
name: 'imageSrc',
type: 'string',
default: 'required',
description: 'The source URL for the sticker image'
},
{
name: 'rotate',
type: 'number',
default: '30',
description: 'The rotation angle in degrees when dragging'
},
{
name: 'peelBackHoverPct',
type: 'number',
default: '30',
description: 'Percentage of peel effect on hover (0-100)'
},
{
name: 'peelBackActivePct',
type: 'number',
default: '40',
description: 'Percentage of peel effect when active/clicked (0-100)'
},
{
name: 'peelDirection',
type: 'number',
default: '0',
description: 'Direction of the peel effect in degrees (0-360)'
},
{
name: 'peelEasing',
type: 'string',
default: 'power3.out',
description: 'GSAP easing function for peel animations'
},
{
name: 'peelHoverEasing',
type: 'string',
default: 'power2.out',
description: 'GSAP easing function for hover transitions'
},
{
name: 'width',
type: 'number',
default: '200',
description: 'Width of the sticker in pixels'
},
{
name: 'shadowIntensity',
type: 'number',
default: '0.6',
description: 'Intensity of the shadow effect (0-1)'
},
{
name: 'lightingIntensity',
type: 'number',
default: '0.1',
description: 'Intensity of the lighting effect (0-1)'
},
{
name: 'initialPosition',
type: 'string',
default: 'center',
description: "Initial position of the sticker ('center', 'top-left', 'top-right', 'bottom-left', 'bottom-right')"
},
{
name: 'className',
type: 'string',
default: '',
description: 'Custom class name for additional styling'
}
];
</script>
<style scoped>
.demo-container {
padding: 0;
}
</style>