Update Animations and UI feedback

This commit is contained in:
Justin Deal 2025-05-03 14:06:52 -07:00
parent fb7ef4c464
commit 9179145830
11 changed files with 756 additions and 56 deletions

View File

@ -12,7 +12,7 @@ const { url, external, class: className } = Astro.props;
<a <a
href={url} href={url}
class:list={[ class:list={[
"zag-offset underline font-medium flex items-center focus:outline-2 focus:outline-offset-2 focus:outline-zag-dark dark:focus:outline-zag-light", "zag-link zag-interactive font-medium flex items-center",
className, className,
]} ]}
target={external ? "_blank" : "_self"} target={external ? "_blank" : "_self"}

View File

@ -0,0 +1,116 @@
---
interface Props {
animation: 'fade' | 'scale' | 'slide-up' | 'slide-down' | 'slide-left' | 'slide-right' | 'pulse';
duration?: number; // in milliseconds
delay?: number; // in milliseconds
easing?: string; // CSS easing function
class?: string;
tag?: string; // HTML tag to use
}
const {
animation,
duration = 300,
delay = 0,
easing = 'ease',
class: className = '',
tag: Tag = 'div'
} = Astro.props;
const animationClasses = {
'fade': 'animate-fade',
'scale': 'animate-scale',
'slide-up': 'animate-slide-up',
'slide-down': 'animate-slide-down',
'slide-left': 'animate-slide-left',
'slide-right': 'animate-slide-right',
'pulse': 'animate-pulse'
};
const animationClass = animationClasses[animation] || '';
---
<Tag class:list={[animationClass, className]}>
<style define:vars={{
animationDuration: `${duration}ms`,
animationDelay: `${delay}ms`,
animationEasing: easing
}}>
.animate-fade {
animation: fade var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
}
.animate-scale {
animation: scale var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
}
.animate-slide-up {
animation: slideUp var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
}
.animate-slide-down {
animation: slideDown var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
}
.animate-slide-left {
animation: slideLeft var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
}
.animate-slide-right {
animation: slideRight var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
}
.animate-pulse {
animation: pulse var(--animationDuration) var(--animationEasing) var(--animationDelay) infinite;
}
</style>
<slot />
</Tag>
<style>
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scale {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideLeft {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideRight {
from { transform: translateX(-20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.animate-fade, .animate-scale, .animate-slide-up,
.animate-slide-down, .animate-slide-left,
.animate-slide-right, .animate-pulse {
animation: none !important;
transition: none !important;
}
}
</style>

View File

@ -0,0 +1,183 @@
---
interface Props {
type?: string;
name: string;
label: string;
placeholder?: string;
required?: boolean;
pattern?: string;
minlength?: number;
maxlength?: number;
min?: number;
max?: number;
value?: string | number;
helperText?: string;
errorMessage?: string;
class?: string;
}
const {
type = 'text',
name,
label,
placeholder = '',
required = false,
pattern,
minlength,
maxlength,
min,
max,
value = '',
helperText = '',
errorMessage = '',
class: className = '',
} = Astro.props;
const id = `input-${name}`;
---
<div class:list={['form-field', className]}>
<label
for={id}
class="block text-sm font-medium mb-1 transition-all duration-200 form-label"
>
{label}{required && <span class="text-zag-button-red ml-1">*</span>}
</label>
<div class="relative">
<input
id={id}
type={type as any}
name={name}
placeholder={placeholder}
required={required}
pattern={pattern}
minlength={minlength}
maxlength={maxlength}
min={min}
max={max}
value={value}
class="form-input w-full px-3 py-2 border-2 border-solid rounded-lg focus:outline-none focus:ring-0 transition-all duration-200 zag-bg zag-text"
/>
<div class="validation-icon absolute right-3 top-1/2 transform -translate-y-1/2 opacity-0">
<!-- Valid icon -->
<svg
class="valid-icon h-5 w-5 text-green-500 hidden"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
<!-- Invalid icon -->
<svg
class="invalid-icon h-5 w-5 text-red-500 hidden"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
</div>
</div>
<!-- Helper text -->
<p class="helper-text mt-1 text-xs text-zag-text-muted">
{helperText}
</p>
<!-- Error message -->
<p class="error-text mt-1 text-xs text-zag-button-red hidden">
{errorMessage || 'Please enter a valid value'}
</p>
</div>
<style>
.form-field {
margin-bottom: 1.5rem;
}
.form-input:focus {
border-color: var(--color-zag-accent-dark);
}
.form-input:focus + .validation-icon {
opacity: 0;
}
.form-input:focus ~ .form-label {
color: var(--color-zag-accent-dark);
}
.form-input:valid:not(:focus):not(:placeholder-shown) {
border-color: #10b981; /* green-500 */
}
.form-input:invalid:not(:focus):not(:placeholder-shown) {
border-color: var(--color-zag-button-red);
}
.form-input:valid:not(:focus):not(:placeholder-shown) + .validation-icon .valid-icon {
display: block;
}
.form-input:invalid:not(:focus):not(:placeholder-shown) + .validation-icon .invalid-icon {
display: block;
}
.form-input:valid:not(:focus):not(:placeholder-shown) + .validation-icon,
.form-input:invalid:not(:focus):not(:placeholder-shown) + .validation-icon {
opacity: 1;
}
.form-input:invalid:not(:focus):not(:placeholder-shown) ~ .helper-text {
display: none;
}
.form-input:invalid:not(:focus):not(:placeholder-shown) ~ .error-text {
display: block;
}
/* Animation for validation icons */
.validation-icon svg {
transform-origin: center;
animation: pop 0.3s ease;
}
@keyframes pop {
0% { transform: scale(0.8) translateY(-50%); opacity: 0; }
100% { transform: scale(1) translateY(-50%); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.validation-icon svg {
animation: none;
}
}
</style>
<script>
// Add client-side validation
document.addEventListener('DOMContentLoaded', () => {
const formInputs = document.querySelectorAll('.form-input');
formInputs.forEach(input => {
const formField = input.closest('.form-field');
const label = formField?.querySelector('label');
input.addEventListener('focus', () => {
if (label) {
label.classList.add('text-zag-accent-dark');
}
});
input.addEventListener('blur', () => {
if (label) {
label.classList.remove('text-zag-accent-dark');
}
});
});
});
</script>

View File

@ -0,0 +1,126 @@
---
interface Props {
size?: 'small' | 'medium' | 'large';
type?: 'spinner' | 'dots' | 'pulse';
color?: string;
class?: string;
}
const {
size = 'medium',
type = 'spinner',
color = 'currentColor',
class: className = '',
} = Astro.props;
const sizeMap = {
small: 'w-4 h-4',
medium: 'w-8 h-8',
large: 'w-12 h-12',
};
const sizeClass = sizeMap[size] || sizeMap.medium;
---
{type === 'spinner' && (
<div class:list={['loading-spinner', sizeClass, className]} style={{ '--spinner-color': color }}>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
</div>
)}
{type === 'dots' && (
<div class:list={['loading-dots', sizeClass, className]} style={{ '--dots-color': color }}>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
)}
{type === 'pulse' && (
<div class:list={['loading-pulse', sizeClass, className]} style={{ '--pulse-color': color }}>
</div>
)}
<style>
/* Spinner animation */
.loading-spinner {
position: relative;
display: inline-block;
}
.spinner-ring {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 2px solid transparent;
border-top-color: var(--spinner-color, currentColor);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.spinner-ring:nth-child(2) {
animation-delay: -0.3s;
}
.spinner-ring:nth-child(3) {
animation-delay: -0.6s;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Dots animation */
.loading-dots {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.loading-dots .dot {
width: 25%;
height: 25%;
background-color: var(--dots-color, currentColor);
border-radius: 50%;
animation: dotBounce 1.4s infinite ease-in-out both;
}
.loading-dots .dot:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dots .dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes dotBounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
/* Pulse animation */
.loading-pulse {
background-color: var(--pulse-color, currentColor);
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0% { transform: scale(0.8); opacity: 0.5; }
50% { transform: scale(1); opacity: 1; }
100% { transform: scale(0.8); opacity: 0.5; }
}
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
.spinner-ring, .loading-dots .dot, .loading-pulse {
animation: none;
}
}
</style>

View File

@ -1,4 +1,6 @@
--- ---
import LoadingIndicator from "./LoadingIndicator.astro";
interface Props { interface Props {
message?: string; message?: string;
subMessage?: string; subMessage?: string;
@ -18,14 +20,16 @@ const {
aria-live="polite" aria-live="polite"
> >
<div class="loading-content text-center p-8 rounded-lg"> <div class="loading-content text-center p-8 rounded-lg">
<!-- Spinner --> <!-- Enhanced loading indicator -->
<div class="spinner-container mb-6 flex justify-center"> <div class="mb-6 flex justify-center">
<div class="spinner"></div> <LoadingIndicator type="dots" size="large" color="var(--color-zag-accent-dark)" />
</div> </div>
<!-- Messages --> <!-- Messages with animation -->
<p class="text-xl font-semibold mb-2 zag-text">{message}</p> <div class="messages-container">
<p class="text-base zag-text-muted">{subMessage}</p> <p class="text-xl font-semibold mb-2 zag-text message-animate">{message}</p>
<p class="text-base zag-text-muted message-animate-delay">{subMessage}</p>
</div>
</div> </div>
</div> </div>
@ -41,26 +45,23 @@ const {
visibility: visible; visibility: visible;
} }
.spinner { /* Message animations */
width: 48px; .message-animate {
height: 48px; animation: fadeSlideUp 0.6s ease-out both;
border: 4px solid var(--color-zag-dark-muted);
border-radius: 50%;
border-top-color: var(--color-zag-accent-dark);
animation: spin 1.5s cubic-bezier(0.68, -0.55, 0.27, 1.55) infinite;
} }
:global(.dark) .spinner { .message-animate-delay {
border-color: var(--color-zag-light-muted); animation: fadeSlideUp 0.6s ease-out 0.2s both;
border-top-color: var(--color-zag-accent-dark);
} }
@keyframes spin { @keyframes fadeSlideUp {
0% { from {
transform: rotate(0deg); opacity: 0;
transform: translateY(10px);
} }
100% { to {
transform: rotate(360deg); opacity: 1;
transform: translateY(0);
} }
} }

View File

@ -0,0 +1,135 @@
---
interface Props {
animation?: 'fade-up' | 'fade-down' | 'fade-left' | 'fade-right' | 'zoom-in' | 'zoom-out';
duration?: number; // in milliseconds
delay?: number; // in milliseconds
threshold?: number; // 0-1, percentage of element visible to trigger
rootMargin?: string; // CSS margin value
once?: boolean; // animate only once or every time element enters viewport
class?: string;
}
const {
animation = 'fade-up',
duration = 600,
delay = 0,
threshold = 0.1,
rootMargin = '0px',
once = true,
class: className = '',
} = Astro.props;
const id = `scroll-reveal-${Math.random().toString(36).substring(2, 9)}`;
---
<div
id={id}
class:list={['scroll-reveal', className]}
data-animation={animation}
data-duration={duration}
data-delay={delay}
data-threshold={threshold}
data-root-margin={rootMargin}
data-once={once}
>
<slot />
</div>
<style>
.scroll-reveal {
opacity: 0;
will-change: transform, opacity;
}
.scroll-reveal[data-animation="fade-up"] {
transform: translateY(30px);
}
.scroll-reveal[data-animation="fade-down"] {
transform: translateY(-30px);
}
.scroll-reveal[data-animation="fade-left"] {
transform: translateX(30px);
}
.scroll-reveal[data-animation="fade-right"] {
transform: translateX(-30px);
}
.scroll-reveal[data-animation="zoom-in"] {
transform: scale(0.9);
}
.scroll-reveal[data-animation="zoom-out"] {
transform: scale(1.1);
}
.scroll-reveal.revealed {
opacity: 1;
transform: translate(0) scale(1);
}
@media (prefers-reduced-motion: reduce) {
.scroll-reveal {
transition: none !important;
opacity: 1 !important;
transform: none !important;
}
}
</style>
<script>
// Initialize scroll reveal animations
document.addEventListener('DOMContentLoaded', () => {
const scrollRevealElements = document.querySelectorAll('.scroll-reveal');
if (!scrollRevealElements.length) return;
// Check if IntersectionObserver is supported
if ('IntersectionObserver' in window) {
scrollRevealElements.forEach(element => {
// Get animation parameters from data attributes
const duration = parseInt(element.getAttribute('data-duration') || '600', 10);
const delay = parseInt(element.getAttribute('data-delay') || '0', 10);
const threshold = parseFloat(element.getAttribute('data-threshold') || '0.1');
const rootMargin = element.getAttribute('data-root-margin') || '0px';
const once = element.getAttribute('data-once') === 'true';
// Set transition based on parameters
(element as HTMLElement).style.transition = `opacity ${duration}ms ease ${delay}ms, transform ${duration}ms ease ${delay}ms`;
// Create observer for this element
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('revealed');
// Unobserve if once is true
if (once) {
observer.unobserve(entry.target);
}
} else if (!once) {
// Remove class if element leaves viewport and once is false
entry.target.classList.remove('revealed');
}
});
},
{
threshold,
rootMargin
}
);
// Start observing
observer.observe(element);
});
} else {
// Fallback for browsers that don't support IntersectionObserver
scrollRevealElements.forEach(element => {
element.classList.add('revealed');
});
}
});
</script>

View File

@ -39,7 +39,7 @@ const { name, href, img, alt } = Astro.props;
<a <a
href={href} href={href}
class="service-card flex items-center transition-all duration-300 hover:opacity-90" class="service-card zag-interactive flex items-center transition-all duration-300"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@ -141,12 +141,56 @@ const { name, href, img, alt } = Astro.props;
height: 6rem; height: 6rem;
} }
/* Hover effect */ /* Enhanced hover effects */
.service-card {
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 2px solid transparent;
background-color: var(--color-zag-bg);
border-radius: 0.5rem;
overflow: hidden;
position: relative;
}
.service-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, transparent 0%, var(--color-zag-accent) 300%);
opacity: 0;
transition: opacity 0.3s ease;
z-index: -1;
}
.service-card:hover { .service-card:hover {
transform: translateY(-2px); transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
border-color: var(--color-zag-accent);
background-color: var(--color-zag-bg-hover);
z-index: 10;
}
.service-card:hover::before {
opacity: 0.1;
}
.service-card:hover .service-icon {
transform: scale(1.1);
} }
:global(.view-mode-list) .service-card:hover { :global(.view-mode-list) .service-card:hover {
transform: translateX(2px); transform: translateX(4px);
}
/* Dark mode adjustments */
:global(.dark) .service-card {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
:global(.dark) .service-card:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
} }
</style> </style>

View File

@ -1,27 +1,34 @@
--- ---
import { GLOBAL } from "../../lib/variables"; import { GLOBAL } from "../../lib/variables";
import OptimizedImage from "../common/OptimizedImage.astro"; import OptimizedImage from "../common/OptimizedImage.astro";
import AnimatedElement from "../common/AnimatedElement.astro";
import profileImage from "../../assets/images/pixel_avatar.png"; import profileImage from "../../assets/images/pixel_avatar.png";
--- ---
<div class="flex flex-col items-center sm:flex-row gap-8"> <div class="flex flex-col items-center sm:flex-row gap-8">
<AnimatedElement animation="scale" duration={600}>
<OptimizedImage <OptimizedImage
src={profileImage} src={profileImage}
alt={GLOBAL.username} alt={GLOBAL.username}
width={160} width={160}
height={160} height={160}
class="rounded-full max-w-40 w-40 h-40 mb-4 z-0 grayscale" class="rounded-full max-w-40 w-40 h-40 mb-4 z-0 grayscale hover:grayscale-0 transition-all duration-500"
/> />
</AnimatedElement>
<div> <div>
<AnimatedElement animation="slide-up" duration={500} delay={200}>
<h1 <h1
class="text-3xl sm:text-4xl font-display font-semibold opsz text-center sm:text-left" class="text-3xl sm:text-4xl font-display font-semibold opsz text-center sm:text-left"
> >
{GLOBAL.username} {GLOBAL.username}
</h1> </h1>
</AnimatedElement>
<AnimatedElement animation="slide-up" duration={500} delay={400}>
<h2 <h2
class="text-center text-xl sm:text-2xl font-mono font-medium sm:text-left" class="text-center text-xl sm:text-2xl font-mono font-medium sm:text-left"
> >
<p set:html={GLOBAL.shortDescription}/> <p set:html={GLOBAL.shortDescription}/>
</h2> </h2>
</AnimatedElement>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
--- ---
import { type Service } from "../../lib/types"; import { type Service } from "../../lib/types";
import ServiceCard from "../common/ServiceCard.astro"; import ServiceCard from "../common/ServiceCard.astro";
import ScrollReveal from "../common/ScrollReveal.astro";
/** /**
* CategorySection component displays a collapsible section of services grouped by category * CategorySection component displays a collapsible section of services grouped by category
@ -71,6 +72,12 @@ const categoryLower = category.toLowerCase();
const appTags = app.tags ? app.tags.join(' ').toLowerCase() : ''; const appTags = app.tags ? app.tags.join(' ').toLowerCase() : '';
return ( return (
<ScrollReveal
animation="fade-up"
delay={100 * apps.indexOf(app)}
duration={500}
threshold={0.1}
>
<div <div
class="app-card transition-all duration-300" class="app-card transition-all duration-300"
data-app-name={appName} data-app-name={appName}
@ -84,6 +91,7 @@ const categoryLower = category.toLowerCase();
alt={app.name} alt={app.name}
/> />
</div> </div>
</ScrollReveal>
); );
}) })
) : ( ) : (

View File

@ -6,7 +6,7 @@ import { type Service, type ServiceCategory } from "../../lib/types";
export const services: ServiceCategory = { export const services: ServiceCategory = {
Websites: [ Websites: [
{ {
name: "https://justin.deal", name: "justin.deal",
link: "https://justin.deal", link: "https://justin.deal",
icon: "/pixel_avatar.png", icon: "/pixel_avatar.png",
alt: "Personal Website" alt: "Personal Website"

View File

@ -80,6 +80,11 @@ html.fonts-loaded body {
--color-zag-accent-dark: #fe8019; /* secondary */ --color-zag-accent-dark: #fe8019; /* secondary */
--color-zag-accent-dark-muted: #fabd2f; /* tertiary */ --color-zag-accent-dark-muted: #fabd2f; /* tertiary */
/* Card hover effect variables */
--color-zag-bg: rgba(235, 219, 178, 0.8); /* Light mode card background */
--color-zag-bg-hover: rgba(235, 219, 178, 1); /* Light mode card hover background */
--color-zag-accent: rgba(184, 187, 38, 0.5); /* Light mode accent border */
/* Additional special colors */ /* Additional special colors */
--color-zag-button-primary: #b8bb26; --color-zag-button-primary: #b8bb26;
--color-zag-button-secondary: #a89984; --color-zag-button-secondary: #a89984;
@ -101,6 +106,81 @@ html.fonts-loaded body {
--zag-transition-timing-function: ease-in-out; --zag-transition-timing-function: ease-in-out;
} }
.dark {
--color-zag-bg: rgba(40, 40, 40, 0.8); /* Dark mode card background */
--color-zag-bg-hover: rgba(40, 40, 40, 1); /* Dark mode card hover background */
--color-zag-accent: rgba(254, 128, 25, 0.5); /* Dark mode accent border */
}
/* Interactive element base transitions */
.zag-interactive {
position: relative;
transition: all 0.2s ease;
transform-origin: center;
}
/* Standard hover effect for buttons and interactive elements */
.zag-interactive:hover {
transform: translateY(-2px);
}
/* Active/pressed state */
.zag-interactive:active {
transform: translateY(0);
}
/* Focus state for keyboard navigation */
.zag-interactive:focus-visible {
outline: 2px solid var(--color-zag-accent-dark);
outline-offset: 2px;
}
/* Button hover effects */
.zag-button {
position: relative;
overflow: hidden;
}
.zag-button::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: currentColor;
opacity: 0;
transition: opacity 0.2s ease;
}
.zag-button:hover::after {
opacity: 0.1;
}
.zag-button:active::after {
opacity: 0.2;
}
/* Link hover effects */
.zag-link {
position: relative;
}
.zag-link::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background-color: currentColor;
transition: width 0.2s ease;
}
.zag-link:hover::after {
width: 100%;
}
.zag-transition { .zag-transition {
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
transition: transition: