feat: animated icon

This commit is contained in:
2025-10-09 16:18:19 +02:00
parent 4e0422bede
commit 75f57159a5
69 changed files with 438 additions and 197 deletions
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
+1 -1
View File
@@ -22,7 +22,7 @@ const { header } = useAppConfig()
<template v-else #left>
<div class="flex items-center gap-6">
<NuxtLink :to="header?.to || '/'" class="flex items-center">
<img src="/icon.svg" alt="kompose" class="size-7 mt-1 mr-2"></img>
<AppIcon size="32px" class="mt-1 mr-2" />
<AppLogo class="!text-2xl mt-1" />
</NuxtLink>
+431 -178
View File
@@ -1,192 +1,445 @@
<script setup>
import { computed } from 'vue'
<template>
<div
class="kompose-icon-wrapper"
:class="{ 'is-clicked': isClicked, 'is-interactive': interactive }"
@click="handleClick"
@mouseenter="handleHover"
@mouseleave="handleLeave"
@touchstart="handleTouch"
:style="{ width: size, height: size }"
>
<svg
class="kompose-icon"
viewBox="0 0 192 192"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern id="carbon192" x="0" y="0" width="7.68" height="7.68" patternUnits="userSpaceOnUse">
<rect width="7.68" height="7.68" fill="#0a0e27"></rect>
<path d="M0,0 L3.84,3.84 M3.84,0 L7.68,3.84 M0,3.84 L3.84,7.68" stroke="#060815" stroke-width="1.5" opacity="0.5"></path>
</pattern>
const props = defineProps({
size: {
type: Number,
default: 512 // Default for PWA, can be 16, 32, 192, 512, etc.
}
<linearGradient id="bgGrad192" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1d2e;stop-opacity:1"></stop>
<stop offset="100%" style="stop-color:#0a0e27;stop-opacity:1"></stop>
</linearGradient>
<linearGradient id="primaryGrad192" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" class="gradient-start" style="stop-color:#00DC82;stop-opacity:1"></stop>
<stop offset="100%" class="gradient-end" style="stop-color:#00a86b;stop-opacity:1"></stop>
</linearGradient>
<filter id="glow192">
<feGaussianBlur stdDeviation="6" result="coloredBlur"></feGaussianBlur>
<feMerge>
<feMergeNode in="coloredBlur"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<filter id="intenseglow192">
<feGaussianBlur stdDeviation="12" result="coloredBlur"></feGaussianBlur>
<feMerge>
<feMergeNode in="coloredBlur"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
</defs>
<!-- Background -->
<rect class="bg-rect" width="192" height="192" rx="24" fill="url(#bgGrad192)"></rect>
<rect class="carbon-pattern" width="192" height="192" rx="24" fill="url(#carbon192)" opacity="0.4"></rect>
<!-- Stylized K -->
<g class="k-letter" transform="translate(48, 48)">
<line class="k-line k-vertical" x1="0" y1="0" x2="0" y2="96" stroke="url(#primaryGrad192)" stroke-width="15" stroke-linecap="round" filter="url(#glow192)"></line>
<line class="k-line k-diagonal-top" x1="0" y1="48" x2="57.6" y2="0" stroke="url(#primaryGrad192)" stroke-width="15" stroke-linecap="round" filter="url(#glow192)"></line>
<line class="k-line k-diagonal-bottom" x1="0" y1="48" x2="57.6" y2="96" stroke="url(#primaryGrad192)" stroke-width="15" stroke-linecap="round" filter="url(#glow192)"></line>
</g>
<!-- Animated status dot -->
<circle class="status-dot" cx="163.2" cy="163.2" r="11.52" fill="#00DC82" opacity="0.9"></circle>
<circle class="status-ring" cx="163.2" cy="163.2" r="17.28" fill="none" stroke="#00DC82" stroke-width="3" opacity="0.3"></circle>
<!-- Tech corners -->
<line class="corner corner-tl-h" x1="15.36" y1="15.36" x2="28.8" y2="15.36" stroke="#00DC82" stroke-width="3" opacity="0.4"></line>
<line class="corner corner-tl-v" x1="15.36" y1="15.36" x2="15.36" y2="28.8" stroke="#00DC82" stroke-width="3" opacity="0.4"></line>
<line class="corner corner-tr-h" x1="176.64" y1="15.36" x2="163.2" y2="15.36" stroke="#00DC82" stroke-width="3" opacity="0.4"></line>
<line class="corner corner-tr-v" x1="176.64" y1="15.36" x2="176.64" y2="28.8" stroke="#00DC82" stroke-width="3" opacity="0.4"></line>
</svg>
<!-- Ripple effect container -->
<div class="ripple" v-if="showRipple"></div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
size?: string
interactive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: '192px',
interactive: true
})
const iconSize = computed(() => props.size)
const strokeWidth = computed(() => props.size / 32) // Scales with size
const isClicked = ref(false)
const showRipple = ref(false)
const handleClick = () => {
if (!props.interactive) return
isClicked.value = true
showRipple.value = true
setTimeout(() => {
isClicked.value = false
}, 600)
setTimeout(() => {
showRipple.value = false
}, 800)
}
const handleHover = () => {
if (!props.interactive) return
// Hover animations are handled by CSS
}
const handleLeave = () => {
if (!props.interactive) return
// Leave animations are handled by CSS
}
const handleTouch = (e: TouchEvent) => {
if (!props.interactive) return
handleClick()
}
</script>
<template>
<svg
:width="iconSize"
:height="iconSize"
:viewBox="`0 0 ${iconSize} ${iconSize}`"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<!-- Carbon fiber pattern -->
<pattern
id="carbon"
x="0"
y="0"
:width="iconSize / 25"
:height="iconSize / 25"
patternUnits="userSpaceOnUse"
>
<rect :width="iconSize / 25" :height="iconSize / 25" fill="#0a0e27"/>
<path
:d="`M0,0 L${iconSize/50},${iconSize/50} M${iconSize/50},0 L${iconSize/25},${iconSize/50} M0,${iconSize/50} L${iconSize/50},${iconSize/25}`"
stroke="#060815"
:stroke-width="strokeWidth / 4"
opacity="0.5"
/>
</pattern>
<style scoped>
.kompose-icon-wrapper {
position: relative;
display: inline-block;
cursor: pointer;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-style: preserve-3d;
}
<!-- Gradient for depth -->
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1d2e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0a0e27;stop-opacity:1" />
</linearGradient>
.kompose-icon-wrapper:not(.is-interactive) {
cursor: default;
}
<!-- Primary color gradient -->
<linearGradient id="primaryGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#00DC82;stop-opacity:1" />
<stop offset="100%" style="stop-color:#00a86b;stop-opacity:1" />
</linearGradient>
.kompose-icon {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 4px 20px rgba(0, 220, 130, 0.2));
transition: filter 0.4s ease;
}
<!-- Glow effect -->
<filter id="glow">
<feGaussianBlur :stdDeviation="strokeWidth" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
/* Hover Effects */
.kompose-icon-wrapper.is-interactive:hover {
transform: scale(1.05) translateY(-2px);
}
<!-- Background with rounded corners -->
<rect
:width="iconSize"
:height="iconSize"
:rx="iconSize / 8"
fill="url(#bgGrad)"
/>
<!-- Carbon texture overlay -->
<rect
:width="iconSize"
:height="iconSize"
:rx="iconSize / 8"
fill="url(#carbon)"
opacity="0.4"
/>
.kompose-icon-wrapper.is-interactive:hover .kompose-icon {
filter: drop-shadow(0 8px 30px rgba(0, 220, 130, 0.4));
animation: subtle-pulse 2s ease-in-out infinite;
}
<!-- Stylized "K" letter with geometric design -->
<g :transform="`translate(${iconSize * 0.25}, ${iconSize * 0.25})`">
<!-- Main vertical line of K -->
<line
:x1="0"
:y1="0"
:x2="0"
:y2="iconSize * 0.5"
stroke="url(#primaryGrad)"
:stroke-width="strokeWidth * 2.5"
stroke-linecap="round"
filter="url(#glow)"
/>
<!-- Upper diagonal of K -->
<line
:x1="0"
:y1="iconSize * 0.25"
:x2="iconSize * 0.3"
:y2="0"
stroke="url(#primaryGrad)"
:stroke-width="strokeWidth * 2.5"
stroke-linecap="round"
filter="url(#glow)"
/>
<!-- Lower diagonal of K -->
<line
:x1="0"
:y1="iconSize * 0.25"
:x2="iconSize * 0.3"
:y2="iconSize * 0.5"
stroke="url(#primaryGrad)"
:stroke-width="strokeWidth * 2.5"
stroke-linecap="round"
filter="url(#glow)"
/>
</g>
.kompose-icon-wrapper.is-interactive:hover .bg-rect {
fill: url(#bgGrad192);
opacity: 1;
animation: bg-glow 2s ease-in-out infinite;
}
<!-- Animated status dot (bottom right) -->
<circle
:cx="iconSize * 0.85"
:cy="iconSize * 0.85"
:r="iconSize * 0.06"
fill="#00DC82"
opacity="0.9"
>
<animate
attributeName="opacity"
values="0.6;1;0.6"
dur="2s"
repeatCount="indefinite"
/>
<animate
attributeName="r"
:values="`${iconSize * 0.06};${iconSize * 0.07};${iconSize * 0.06}`"
dur="2s"
repeatCount="indefinite"
/>
</circle>
.kompose-icon-wrapper.is-interactive:hover .k-letter {
animation: letter-glow 1.5s ease-in-out infinite;
}
<!-- Glow ring around dot -->
<circle
:cx="iconSize * 0.85"
:cy="iconSize * 0.85"
:r="iconSize * 0.09"
fill="none"
stroke="#00DC82"
:stroke-width="strokeWidth / 2"
opacity="0.3"
/>
.kompose-icon-wrapper.is-interactive:hover .k-vertical {
animation: line-slide-vertical 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
}
<!-- Tech corner accents -->
<line
:x1="iconSize * 0.08"
:y1="iconSize * 0.08"
:x2="iconSize * 0.15"
:y2="iconSize * 0.08"
stroke="#00DC82"
:stroke-width="strokeWidth / 2"
opacity="0.4"
/>
<line
:x1="iconSize * 0.08"
:y1="iconSize * 0.08"
:x2="iconSize * 0.08"
:y2="iconSize * 0.15"
stroke="#00DC82"
:stroke-width="strokeWidth / 2"
opacity="0.4"
/>
.kompose-icon-wrapper.is-interactive:hover .k-diagonal-top {
animation: line-slide-diagonal-top 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.1s;
}
<line
:x1="iconSize * 0.92"
:y1="iconSize * 0.08"
:x2="iconSize * 0.85"
:y2="iconSize * 0.08"
stroke="#00DC82"
:stroke-width="strokeWidth / 2"
opacity="0.4"
/>
<line
:x1="iconSize * 0.92"
:y1="iconSize * 0.08"
:x2="iconSize * 0.92"
:y2="iconSize * 0.15"
stroke="#00DC82"
:stroke-width="strokeWidth / 2"
opacity="0.4"
/>
</svg>
</template>
.kompose-icon-wrapper.is-interactive:hover .k-diagonal-bottom {
animation: line-slide-diagonal-bottom 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s;
}
.kompose-icon-wrapper.is-interactive:hover .status-dot {
animation: pulse-expand 1s ease-in-out infinite;
}
.kompose-icon-wrapper.is-interactive:hover .status-ring {
animation: ring-pulse 1.5s ease-in-out infinite;
}
.kompose-icon-wrapper.is-interactive:hover .corner {
opacity: 1 !important;
stroke: #00DC82;
animation: corner-extend 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Click/Active Effects */
.kompose-icon-wrapper.is-clicked {
animation: click-bounce 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.kompose-icon-wrapper.is-clicked .kompose-icon {
animation: rotate-3d 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
filter: drop-shadow(0 12px 40px rgba(0, 220, 130, 0.6));
}
.kompose-icon-wrapper.is-clicked .k-letter {
animation: letter-flash 0.6s ease-out;
filter: url(#intenseglow192);
}
.kompose-icon-wrapper.is-clicked .status-dot {
animation: dot-burst 0.6s ease-out;
}
/* Ripple Effect */
.ripple {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
border-radius: 50%;
background: radial-gradient(circle, rgba(0, 220, 130, 0.6) 0%, rgba(0, 220, 130, 0) 70%);
transform: translate(-50%, -50%) scale(0);
animation: ripple-expand 0.8s ease-out;
pointer-events: none;
}
/* Default animations for status dot */
.status-dot {
animation: default-pulse 2s ease-in-out infinite;
}
.status-ring {
animation: default-ring-pulse 2s ease-in-out infinite;
}
/* Keyframe Animations */
@keyframes subtle-pulse {
0%, 100% {
filter: drop-shadow(0 8px 30px rgba(0, 220, 130, 0.4));
}
50% {
filter: drop-shadow(0 8px 35px rgba(0, 220, 130, 0.6));
}
}
@keyframes bg-glow {
0%, 100% {
filter: brightness(1);
}
50% {
filter: brightness(1.1);
}
}
@keyframes letter-glow {
0%, 100% {
filter: url(#glow192);
}
50% {
filter: url(#intenseglow192);
}
}
@keyframes line-slide-vertical {
0% {
stroke-dasharray: 96;
stroke-dashoffset: 96;
}
100% {
stroke-dasharray: 96;
stroke-dashoffset: 0;
}
}
@keyframes line-slide-diagonal-top {
0% {
stroke-dasharray: 68;
stroke-dashoffset: 68;
}
100% {
stroke-dasharray: 68;
stroke-dashoffset: 0;
}
}
@keyframes line-slide-diagonal-bottom {
0% {
stroke-dasharray: 68;
stroke-dashoffset: 68;
}
100% {
stroke-dasharray: 68;
stroke-dashoffset: 0;
}
}
@keyframes pulse-expand {
0%, 100% {
r: 11.52;
opacity: 0.9;
}
50% {
r: 14;
opacity: 1;
}
}
@keyframes ring-pulse {
0%, 100% {
r: 17.28;
opacity: 0.3;
stroke-width: 3;
}
50% {
r: 20;
opacity: 0.6;
stroke-width: 2;
}
}
@keyframes corner-extend {
0% {
stroke-dasharray: 13.44;
stroke-dashoffset: 13.44;
}
100% {
stroke-dasharray: 13.44;
stroke-dashoffset: 0;
}
}
@keyframes click-bounce {
0% {
transform: scale(1) translateY(0) rotateY(0deg);
}
30% {
transform: scale(0.92) translateY(0) rotateY(0deg);
}
50% {
transform: scale(1.08) translateY(-4px) rotateY(180deg);
}
70% {
transform: scale(0.98) translateY(0) rotateY(360deg);
}
100% {
transform: scale(1) translateY(0) rotateY(360deg);
}
}
@keyframes rotate-3d {
0% {
transform: perspective(800px) rotateY(0deg);
}
50% {
transform: perspective(800px) rotateY(180deg);
}
100% {
transform: perspective(800px) rotateY(360deg);
}
}
@keyframes letter-flash {
0%, 100% {
opacity: 1;
}
20%, 60% {
opacity: 0.7;
}
40%, 80% {
opacity: 1;
}
}
@keyframes dot-burst {
0% {
r: 11.52;
opacity: 0.9;
}
50% {
r: 20;
opacity: 1;
}
100% {
r: 11.52;
opacity: 0.9;
}
}
@keyframes ripple-expand {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(2.5);
opacity: 0;
}
}
@keyframes default-pulse {
0%, 100% {
opacity: 0.6;
r: 11.52;
}
50% {
opacity: 1;
r: 13.44;
}
}
@keyframes default-ring-pulse {
0%, 100% {
opacity: 0.3;
}
50% {
opacity: 0.5;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.kompose-icon-wrapper.is-interactive:hover {
transform: scale(1.03) translateY(-1px);
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.kompose-icon-wrapper,
.kompose-icon,
.kompose-icon *,
.ripple {
animation: none !important;
transition: none !important;
}
.kompose-icon-wrapper.is-interactive:hover {
transform: scale(1.02);
}
}
/* Touch device optimizations */
@media (hover: none) and (pointer: coarse) {
.kompose-icon-wrapper.is-interactive:active {
transform: scale(0.95);
}
}
</style>
View File
View File
View File
View File
+6 -18
View File
@@ -3,11 +3,11 @@
v-slot="{ open }"
:modal="false"
:items="[{
label: 'Starter',
to: 'https://starter-template.nuxt.dev/'
label: 'Blog',
to: 'https://pivoine.art'
}, {
label: 'Landing',
to: 'https://landing-template.nuxt.dev/'
label: 'Code',
to: 'https://code.pivoine.art'
}, {
label: 'Docs',
to: 'https://docs-template.nuxt.dev/',
@@ -15,20 +15,8 @@
checked: true,
type: 'checkbox'
}, {
label: 'SaaS',
to: 'https://saas-template.nuxt.dev/'
}, {
label: 'Dashboard',
to: 'https://dashboard-template.nuxt.dev/'
}, {
label: 'Chat',
to: 'https://chat-template.nuxt.dev/'
}, {
label: 'Portfolio',
to: 'https://portfolio-template.nuxt.dev/'
}, {
label: 'Changelog',
to: 'https://changelog-template.nuxt.dev/'
label: 'Sexy',
to: 'https://sexy.pivoine.art'
}]"
:content="{ align: 'start' }"
:ui="{ content: 'min-w-fit' }"
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
Generated Regular → Executable
View File
View File
View File

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

View File
View File
View File