Adds interactive animated components for homepage

This commit is contained in:
Alicia Sykes 2024-06-03 23:38:35 +01:00
parent fa6ef6f929
commit 68f95d503c
4 changed files with 582 additions and 0 deletions

View File

@ -0,0 +1,94 @@
---
const buttonText = 'Analyze URL';
const buttonType = 'submit';
---
<button class="button" type={buttonType}>{buttonText}</button>
<style lang="scss">
@property --angle {
syntax: '<angle>';
initial-value: 90deg;
inherits: true;
}
@property --gradX {
syntax: '<percentage>';
initial-value: 50%;
inherits: true;
}
@property --gradY {
syntax: '<percentage>';
initial-value: 0%;
inherits: true;
}
.button {
padding: 1rem;
background: transparent;
font-size: 2rem;
color: var(--text-color-secondary);
border: 2px solid var(--text-color-thirdly);
border-radius: 2px;
font-weight: bold;
background: var(--background);
cursor: pointer;
overflow: hidden;
--angle: 90deg;
--gradX: 100%;
--gradY: 50%;
--c1: var(--primary);
--c2: var(--text-color-thirdly);
border-image: conic-gradient(from var(--angle), var(--c2), var(--c1) 0.5turn, var(--c1) 0.15turn, var(--c2) 0.25turn) 1;
animation: borderRotate 3500ms linear infinite forwards;
transition: border 0.3s ease-in-out;
&:hover {
border: 2px solid var(--primary);
}
}
@keyframes borderRotate {
0% { --angle: 90deg; }
25% { --angle: 180deg; }
50% { --angle: 270deg; }
75% { --angle: 360deg; }
100% { --angle: 450deg; }
}
</style>
<script>
// Workaround to add fancy border animation for users who use a REAL browser
// Since CSS @property attribute isn't supported in Firefox yet
document.addEventListener('DOMContentLoaded', () => {
const isFirefox = typeof (window as any).InstallTrigger !== 'undefined';
if (isFirefox) {
const button = document.querySelector('.button') as HTMLElement | null;
if (button) {
const duration = 3500;
const startAngle = 90;
const endAngle = 420;
function animateAngle() {
let start: number | null = null;
function step(timestamp: number) {
if (!start) start = timestamp;
const progress = (timestamp - start) / duration;
const angle = startAngle + progress * (endAngle - startAngle);
button && button.style.setProperty('--angle', `${angle}deg`);
if (progress < 1) {
window.requestAnimationFrame(step);
} else {
start = null;
window.requestAnimationFrame(step);
}
}
window.requestAnimationFrame(step);
}
animateAngle();
}
}
});
</script>

View File

@ -0,0 +1,186 @@
---
const placeholders = [
'duck.com',
'github.com',
'google.com',
'x.com',
'bbc.co.uk',
'wikipedia.org',
'openai.com',
];
---
<div class="input-container">
<input
required
id="url-input"
type="url"
name="url"
placeholder="E.g. duck.com"
/>
<div class="placeholder-container">
<span class="starter" aria-hidden="true">E.g.</span>
{placeholders.map((placeholder, index) => (
<span class={`placeholder ${index === 0 ? 'active' : ''}`} aria-hidden="true">{placeholder}</span>
))}
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Grab the DOM elements we need
const placeholders = document.querySelectorAll('.placeholder');
const starter = document.querySelector('.starter') as HTMLElement;
const inputField = document.getElementById('url-input') as HTMLInputElement;
// Variables for the configuring + tracking the placeholder animation
let currentIndex = 0;
const timeBetweenPlaceholders = 3000;
let interval: ReturnType<typeof setInterval>;
// Function to change which placeholder is currently visible
const changePlaceholder = () => {
placeholders.forEach((el, index) => {
starter.classList.remove('hide');
if (index === currentIndex) {
el.classList.add('active');
el.classList.remove('inactive');
el.classList.remove('still-inactive');
} else {
el.classList.add('inactive');
el.classList.remove('active');
setTimeout(() => {
el.classList.add('still-inactive');
}, timeBetweenPlaceholders / 5);
}
});
currentIndex = (currentIndex + 1) % placeholders.length;
};
// Begin the placeholder animation
const startPlaceholderAnimation = () => {
interval = setInterval(changePlaceholder, timeBetweenPlaceholders);
};
// Stop the placeholder animation
const stopPlaceholderAnimation = () => {
clearInterval(interval);
starter.classList.add('hide');
placeholders.forEach((el) => {
el.classList.remove('active');
el.classList.add('inactive');
});
};
// When user focuses on the input field, stop the animation
inputField.addEventListener('focus', () => {
stopPlaceholderAnimation();
});
// And, when user un-focuses on input, resume the placeholder animation
inputField.addEventListener('blur', () => {
if (!inputField.value) {
startPlaceholderAnimation();
}
});
// If user types something, stop the animation
inputField.addEventListener('input', () => {
if (inputField.value) {
stopPlaceholderAnimation();
} else if (document.activeElement !== inputField) {
startPlaceholderAnimation();
}
});
// Disable the input's placeholder attribute
inputField.setAttribute('placeholder', '');
// Make visible the placeholder container
(document.querySelector('.placeholder-container') as HTMLElement).style.display = 'flex';
// And finally, start the animation!
startPlaceholderAnimation();
});
</script>
<style lang="scss">
@import '@styles/typography.scss';
@import '@styles/global.scss';
.input-container {
position: relative;
overflow: hidden;
input {
width: 100%;
padding: 1rem;
background: var(--background-50);
font-size: 2rem;
color: var(--text-color-secondary);
border: 2px solid var(--text-color);
border-radius: 2px;
transition: all 0.3s ease-in-out;
&:focus {
border-color: var(--primary);
}
}
}
.input-container .placeholder-container {
display: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
align-items: center;
padding-left: 16px;
pointer-events: none;
}
.starter, .placeholder {
position: absolute;
width: 100%;
font-size: 2rem;
color: var(--text-color-thirdly);
padding: 0.1rem 0;
user-select: none;
}
.starter {
transition: opacity 0.6s, transform 0.5s;
&.hide {
opacity: 0;
transform: translateX(-50%);
}
}
.placeholder {
padding-left: 4rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: transform 0.5s ease-in-out, opacity 0.6s;
opacity: 0;
transform: translateY(-150%);
}
.placeholder.active {
transform: translateY(0);
opacity: 1;
}
.placeholder.inactive {
transform: translateY(150%);
opacity: 0;
}
.placeholder.still-inactive {
transform: translateY(-150%);
opacity: 0;
}
</style>

View File

@ -0,0 +1,128 @@
---
import AnimatedButton from "./AnimatedButton.astro"
import AnimatedInput from "./AnimatedInput.astro"
---
<div class="left">
<h1>
<img src="/favicon.svg" alt="Check Web" width="64" />
<span class="web">Web</span>
<span class="check">Check</span>
</h1>
<div class="homepage-action-content">
<h2>We give you X-Ray<br />Vision for your Website</h2>
<h3>
In just 20 seconds, you can see
<span>what attackers already know</span>
</h3>
<form name="live-start" autocomplete="off" action="/check" class="live-start" id="live-start">
<label for="url">Enter a URL to start 👇</label>
<AnimatedInput />
<AnimatedButton />
</form>
</div>
<div>X</div>
</div>
<script>
/**
* Form management actions (validation, submission, etc.)
* We just use normal, old school JavaScript for this
*/
// Select the form and input elements from the DOM
const form = document.getElementById('live-start');
const urlInput = document.getElementById('url-input') as HTMLInputElement;
// Submit Event - called when user submits form with a valid URL
// Gets and checks the URL, then redirects user to /check/:url
form?.addEventListener('submit', (event) => {
event.preventDefault();
const url = urlInput.value.trim();
if (url) {
const encodedUrl = encodeURIComponent(url);
window.location.href = `/check/${encodedUrl}`;
}
});
// User presses enter, forgets to add protocol
// Will add https:// to the URL, and retry form submit
urlInput?.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
const url = urlInput.value.trim();
const urlWithoutProtocolRegex = /^[a-zA-Z0-9]+[a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$/;
if (url && !/^https?:\/\//i.test(url) && urlWithoutProtocolRegex.test(url)) {
urlInput.value = 'https://' + url;
form?.dispatchEvent(new Event('submit'));
}
}
});
</script>
<style lang="scss">
@import '@styles/global.scss';
.left {
min-height: 100vh;
display: flex;
flex-direction: column;
gap: 20vh;
@include tablet-landscape-down {
gap: 6rem;
}
@include mobile-down {
gap: 4rem;
}
h1 {
margin: 0;
fontis-size: 3em;
z-index: 5;
display: flex;
gap: 0.1rem;
align-items: center;
img {
vertical-align: middle;
width: 3rem;
margin-right: 0.5rem;
}
.web {
color: var(--text-color);
}
.check {
color: var(--primary);
font-style: italic;
}
}
.homepage-action-content {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 700px;
z-index: 1;
@include tablet-landscape-down {
gap: 0.25rem;
}
h2 {
font-size: 3rem;
font-weight: bold;
}
h3 {
font-weight: normal;
font-size: 1.3rem;
span {
color: var(--primary);
font-style: italic;
}
}
form {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
label {
font-size: 1.3rem;
font-weight: bold;
}
}
}
}
</style>

View File

@ -0,0 +1,174 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import styled from '@emotion/styled';
// Global Animation Configuration Constants
const dotSpacing = 32; // Number of px between each dot
const meteorCount = 4; // Number of meteors to display at any given time
const tailLength = 80; // Length of the meteor tail in px
const distanceBase = 5; // Base distance for meteor to travel in grid units
const distanceVariance = 5; // Variance for randomization to append to travel in grid units
const durationBase = 1.5; // Base duration for meteor to travel in seconds
const durationVariance = 1; // Variance for randomization to append to travel in seconds
const delayBase = 500; // Base delay for meteor to respawn in milliseconds
const delayVariance = 1500; // Variance for randomization to append to respawn in milliseconds
const tailDuration = 0.25; // Duration for meteor tail to retract in seconds
const headEasing = [0.8, 0.6, 1, 1]; // Easing for meteor head
const tailEasing = [0.5, 0.6, 0.6, 1]; // Easing for meteor tail
const MeteorContainer = styled(motion.div)`
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background-color: #9fef00;
top: 1px;
`;
const Tail = styled(motion.div)`
position: absolute;
top: -80px;
left: 1px;
width: 2px;
height: 80px;
background: linear-gradient(to bottom, transparent, #9fef00);
`;
const StyledSvg = styled.svg`
pointer-events: none;
position: absolute;
inset: 0;
height: 100%;
width: 100%;
fill: rgba(100, 100, 100, 0.5);
defs pattern circle {
z-index: 1;
}
`;
const StyledRect = styled.rect`
width: 100%;
height: 100%;
stroke-width: 0;
`;
const Container = styled.div`
pointer-events: none;
position: absolute;
height: 100vh;
width: 100vw;
z-index: 1;
top: 0;
left: 0;
background: radial-gradient(circle at center top, transparent, transparent 60%, var(--background) 100%);
`;
const generateMeteor = (id: number, gridSizeX: number, gridSizeY: number) => {
const column = Math.floor(Math.random() * gridSizeX);
const startRow = Math.floor(Math.random() * (gridSizeY - 12));
const travelDistance = distanceBase + Math.floor(Math.random() * distanceVariance);
const duration = durationBase + Math.floor(Math.random() * durationVariance);
return {
id,
column,
startRow,
endRow: startRow + travelDistance,
duration,
tailVisible: true,
animationStage: 'traveling',
opacity: 1, // Initial opacity
};
};
const generateInitialMeteors = (gridSizeX: number, gridSizeY: number) => {
const seen = new Set();
return Array.from({ length: meteorCount }, (_, index) => generateMeteor(index, gridSizeX, gridSizeY))
.filter(item => !seen.has(item.column) && seen.add(item.column));
};
const WebCheckHomeBackground = () => {
const [gridSizeX, setGridSizeX] = useState(Math.floor(window.innerWidth / dotSpacing));
const [gridSizeY, setGridSizeY] = useState(Math.floor(window.innerHeight / dotSpacing));
const [meteors, setMeteors] = useState(() => generateInitialMeteors(gridSizeX, gridSizeY));
const handleAnimationComplete = (id: number) => {
setMeteors(current =>
current.map(meteor => {
if (meteor.id === id) {
if (meteor.animationStage === 'traveling') {
// Transition to retracting tail
return { ...meteor, tailVisible: false, animationStage: 'retractingTail' };
} else if (meteor.animationStage === 'retractingTail') {
// Set to resetting and make invisible
return { ...meteor, animationStage: 'resetting', opacity: 0 };
} else if (meteor.animationStage === 'resetting') {
// Respawn the meteor after a delay
setTimeout(() => {
setMeteors(current =>
current.map(m => m.id === id ? generateMeteor(id, gridSizeX, gridSizeY) : m)
);
}, delayBase + Math.random() * delayVariance);
}
}
return meteor;
})
);
};
useEffect(() => {
const handleResize = () => {
setGridSizeX(Math.floor(window.innerWidth / dotSpacing));
setGridSizeY(Math.floor(window.innerHeight / dotSpacing));
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<>
<Container />
<StyledSvg>
<defs>
<pattern id="dot-pattern" width={dotSpacing} height={dotSpacing} patternUnits="userSpaceOnUse">
<circle cx={1} cy={1} r={2} />
</pattern>
</defs>
<StyledRect fill="url(#dot-pattern)" />
</StyledSvg>
{meteors.map(({ id, column, startRow, endRow, duration, tailVisible, animationStage, opacity }) => {
return (
<MeteorContainer
key={id}
initial={{
x: column * dotSpacing,
y: startRow * dotSpacing,
opacity: 1,
}}
animate={{
opacity: tailVisible ? 1 : 0,
y: animationStage === 'resetting' ? startRow * dotSpacing : endRow * dotSpacing,
}}
transition={{
duration: animationStage === 'resetting' ? 0 : duration,
ease: headEasing,
}}
onAnimationComplete={() => handleAnimationComplete(id)}
>
<Tail
initial={{ top: `-${tailLength}px`, height: `${tailLength}px` }}
animate={{ top: tailVisible ? `-${tailLength}px` : 0, height: tailVisible ? `${tailLength}px` : 0 }}
transition={{
duration: tailDuration,
ease: tailEasing,
}}
/>
</MeteorContainer>
);
})}
</>
);
};
export default WebCheckHomeBackground;