Footer überarbeitet: neues Design mit Flickering-Grid-Schriftzug

Neues Footer-Layout (Link-Spalten mit Chevron-Hover + animierter
WEBklar-Schriftzug via FlickeringGrid). Inhalt (Branding, Seiten-,
Kontakt- und Rechtliches-Links) aus dem bisherigen Footer übernommen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-12 09:25:30 +00:00
parent 7e58e43e18
commit e821233ff6
2 changed files with 353 additions and 77 deletions

View File

@@ -1,91 +1,120 @@
import React from "react";
import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react";
import Logo from "@/components/Logo";
import { FlickeringGrid } from "@/components/ui/flickering-grid";
import { useIsMobile } from "@/hooks/use-mobile";
type FooterLink = {
id: number;
title: string;
/** internal react-router route */
to?: string;
/** anchor / mailto / tel / external */
href?: string;
};
const footerLinks: { title: string; links: FooterLink[] }[] = [
{
title: "Seiten",
links: [
{ id: 1, title: "Startseite", to: "/" },
{ id: 2, title: "Über uns", to: "/ueber-uns" },
{ id: 3, title: "Leistungen", href: "/#services" },
{ id: 4, title: "Kontakt", to: "/kontakt" },
],
},
{
title: "Kontakt",
links: [
{ id: 5, title: "support@webklar.com", href: "mailto:support@webklar.com" },
{ id: 6, title: "0170 4969375", href: "tel:+491704969375" },
],
},
{
title: "Rechtliches",
links: [
{ id: 7, title: "AGB", to: "/agb" },
{ id: 8, title: "Impressum", to: "/impressum" },
],
},
];
const Footer: React.FC = () => {
const isMobile = useIsMobile();
return (
<div className="relative w-full overflow-hidden border-t border-white/[0.1] bg-black px-8 py-20 dark:border-white/[0.1] dark:bg-black">
<div className="mx-auto flex max-w-7xl flex-col items-start justify-between gap-12 text-sm text-neutral-500 sm:flex-row md:px-8">
<div>
<div className="mr-0 mb-4 md:mr-4 md:flex">
<Link
to="/"
className="relative z-20 mr-4 flex items-center space-x-2 px-2 py-1 text-sm font-normal text-white"
>
<Logo width={30} height={30} />
<span className="font-medium text-white">WEBklar</span>
</Link>
</div>
<p className="mt-2 ml-2 max-w-xs leading-relaxed">
Webagentur für KMU Website, Prozesse und Automatisierung aus einer Hand.
<footer
id="footer"
className="w-full overflow-hidden border-t border-white/[0.1] bg-black pb-0"
>
<div className="mx-auto flex max-w-7xl flex-col p-10 md:flex-row md:items-start md:justify-between">
<div className="mx-0 flex max-w-xs flex-col items-start justify-start gap-y-5">
<Link to="/" className="flex items-center gap-2">
<Logo width={32} height={32} />
<span className="text-xl font-semibold text-white">WEBklar</span>
</Link>
<p className="font-medium leading-relaxed tracking-tight text-neutral-400">
Webagentur für KMU Website, Prozesse und Automatisierung aus einer
Hand.
</p>
<div className="mt-4 ml-2">© {new Date().getFullYear()} WEBklar. Alle Rechte vorbehalten.</div>
<div className="text-sm text-neutral-500">
© {new Date().getFullYear()} WEBklar. Alle Rechte vorbehalten.
</div>
</div>
<div className="grid grid-cols-2 items-start gap-10 sm:grid-cols-3 lg:gap-16">
<div className="flex flex-col space-y-4">
<p className="font-bold text-neutral-300">Seiten</p>
<ul className="list-none space-y-3 text-neutral-400">
<li>
<Link to="/" className="hover:text-neutral-200 transition-colors">
Startseite
</Link>
</li>
<li>
<Link to="/ueber-uns" className="hover:text-neutral-200 transition-colors">
Über uns
</Link>
</li>
<li>
<a href="/#services" className="hover:text-neutral-200 transition-colors">
Leistungen
</a>
</li>
<li>
<Link to="/kontakt" className="hover:text-neutral-200 transition-colors">
Kontakt
</Link>
</li>
</ul>
</div>
<div className="flex flex-col space-y-4">
<p className="font-bold text-neutral-300">Kontakt</p>
<ul className="list-none space-y-3 text-neutral-400">
<li>
<a
href="mailto:support@webklar.com"
className="hover:text-neutral-200 transition-colors"
>
support@webklar.com
</a>
</li>
<li>
<a href="tel:+491704969375" className="hover:text-neutral-200 transition-colors">
0170 4969375
</a>
</li>
</ul>
</div>
<div className="flex flex-col space-y-4">
<p className="font-bold text-neutral-300">Rechtliches</p>
<ul className="list-none space-y-3 text-neutral-400">
<li>
<Link to="/agb" className="hover:text-neutral-200 transition-colors">
AGB
</Link>
</li>
<li>
<Link to="/impressum" className="hover:text-neutral-200 transition-colors">
Impressum
</Link>
</li>
</ul>
<div className="pt-10 md:w-1/2 md:pt-0">
<div className="flex flex-col items-start justify-start gap-y-8 sm:flex-row sm:justify-between lg:pl-10">
{footerLinks.map((column, columnIndex) => (
<ul key={columnIndex} className="flex flex-col gap-y-3">
<li className="mb-1 text-sm font-semibold text-neutral-300">
{column.title}
</li>
{column.links.map((link) => (
<li
key={link.id}
className="group inline-flex cursor-pointer items-center justify-start gap-1 text-[15px]/snug text-neutral-400"
>
{link.to ? (
<Link
to={link.to}
className="transition-colors group-hover:text-neutral-200"
>
{link.title}
</Link>
) : (
<a
href={link.href}
className="transition-colors group-hover:text-neutral-200"
>
{link.title}
</a>
)}
<div className="flex size-4 translate-x-0 transform items-center justify-center rounded border border-white/[0.15] opacity-0 transition-all duration-300 ease-out group-hover:translate-x-1 group-hover:opacity-100">
<ChevronRight className="h-4 w-4" />
</div>
</li>
))}
</ul>
))}
</div>
</div>
</div>
<p className="inset-x-0 mt-20 bg-gradient-to-b from-black via-neutral-950 to-neutral-900 bg-clip-text text-center text-5xl font-bold text-transparent md:text-9xl lg:text-[12rem] xl:text-[13rem]">
WEBklar
</p>
</div>
<div className="relative z-0 mt-24 h-48 w-full md:h-64">
<div className="absolute inset-0 z-10 bg-gradient-to-t from-transparent from-40% to-black" />
<div className="absolute inset-0 mx-6">
<FlickeringGrid
text="WEBklar"
fontSize={isMobile ? 70 : 90}
className="h-full w-full"
squareSize={2}
gridGap={isMobile ? 2 : 3}
color="#6B7280"
maxOpacity={0.3}
flickerChance={0.1}
/>
</div>
</div>
</footer>
);
};

View File

@@ -0,0 +1,247 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
// Parse a CSS color string ("#RRGGBB", "#RGB", "rgb(r,g,b)") into [r, g, b].
// Keeps this component dependency-free (no color-bits needed) since the grid
// only ever receives a fixed hex color.
const parseColor = (color: string): [number, number, number] => {
const fallback: [number, number, number] = [180, 180, 180];
if (!color) return fallback;
const hex = color.trim();
if (hex.startsWith("#")) {
let h = hex.slice(1);
if (h.length === 3) {
h = h
.split("")
.map((c) => c + c)
.join("");
}
if (h.length === 6) {
const num = parseInt(h, 16);
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
}
return fallback;
}
const rgbMatch = hex.match(/rgba?\(([^)]+)\)/);
if (rgbMatch) {
const parts = rgbMatch[1].split(",").map((p) => parseFloat(p.trim()));
if (parts.length >= 3) {
return [parts[0], parts[1], parts[2]];
}
}
return fallback;
};
interface FlickeringGridProps extends React.HTMLAttributes<HTMLDivElement> {
squareSize?: number;
gridGap?: number;
flickerChance?: number;
color?: string;
width?: number;
height?: number;
className?: string;
maxOpacity?: number;
text?: string;
fontSize?: number;
fontWeight?: number | string;
}
export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
squareSize = 3,
gridGap = 3,
flickerChance = 0.2,
color = "#B4B4B4",
width,
height,
className,
maxOpacity = 0.15,
text = "",
fontSize = 140,
fontWeight = 600,
...props
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isInView, setIsInView] = useState(false);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const rgb = useMemo(() => parseColor(color), [color]);
const drawGrid = useCallback(
(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
cols: number,
rows: number,
squares: Float32Array,
dpr: number,
) => {
ctx.clearRect(0, 0, width, height);
const maskCanvas = document.createElement("canvas");
maskCanvas.width = width;
maskCanvas.height = height;
const maskCtx = maskCanvas.getContext("2d", { willReadFrequently: true });
if (!maskCtx) return;
if (text) {
maskCtx.save();
maskCtx.scale(dpr, dpr);
maskCtx.fillStyle = "white";
maskCtx.font = `${fontWeight} ${fontSize}px "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
maskCtx.textAlign = "center";
maskCtx.textBaseline = "middle";
maskCtx.fillText(text, width / (2 * dpr), height / (2 * dpr));
maskCtx.restore();
}
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const x = i * (squareSize + gridGap) * dpr;
const y = j * (squareSize + gridGap) * dpr;
const squareWidth = squareSize * dpr;
const squareHeight = squareSize * dpr;
const maskData = maskCtx.getImageData(
x,
y,
squareWidth,
squareHeight,
).data;
const hasText = maskData.some(
(value, index) => index % 4 === 0 && value > 0,
);
const opacity = squares[i * rows + j];
const finalOpacity = hasText
? Math.min(1, opacity * 3 + 0.4)
: opacity;
ctx.fillStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${finalOpacity})`;
ctx.fillRect(x, y, squareWidth, squareHeight);
}
}
},
[rgb, squareSize, gridGap, text, fontSize, fontWeight],
);
const setupCanvas = useCallback(
(canvas: HTMLCanvasElement, width: number, height: number) => {
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const cols = Math.ceil(width / (squareSize + gridGap));
const rows = Math.ceil(height / (squareSize + gridGap));
const squares = new Float32Array(cols * rows);
for (let i = 0; i < squares.length; i++) {
squares[i] = Math.random() * maxOpacity;
}
return { cols, rows, squares, dpr };
},
[squareSize, gridGap, maxOpacity],
);
const updateSquares = useCallback(
(squares: Float32Array, deltaTime: number) => {
for (let i = 0; i < squares.length; i++) {
if (Math.random() < flickerChance * deltaTime) {
squares[i] = Math.random() * maxOpacity;
}
}
},
[flickerChance, maxOpacity],
);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animationFrameId: number;
let gridParams: ReturnType<typeof setupCanvas>;
const updateCanvasSize = () => {
const newWidth = width || container.clientWidth;
const newHeight = height || container.clientHeight;
setCanvasSize({ width: newWidth, height: newHeight });
gridParams = setupCanvas(canvas, newWidth, newHeight);
};
updateCanvasSize();
let lastTime = 0;
const animate = (time: number) => {
if (!isInView) return;
const deltaTime = (time - lastTime) / 1000;
lastTime = time;
updateSquares(gridParams.squares, deltaTime);
drawGrid(
ctx,
canvas.width,
canvas.height,
gridParams.cols,
gridParams.rows,
gridParams.squares,
gridParams.dpr,
);
animationFrameId = requestAnimationFrame(animate);
};
const resizeObserver = new ResizeObserver(() => {
updateCanvasSize();
});
resizeObserver.observe(container);
const intersectionObserver = new IntersectionObserver(
([entry]) => {
setIsInView(entry.isIntersecting);
},
{ threshold: 0 },
);
intersectionObserver.observe(canvas);
if (isInView) {
animationFrameId = requestAnimationFrame(animate);
}
return () => {
cancelAnimationFrame(animationFrameId);
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}, [setupCanvas, updateSquares, drawGrid, width, height, isInView]);
return (
<div
ref={containerRef}
className={cn("h-full w-full", className)}
{...props}
>
<canvas
ref={canvasRef}
className="pointer-events-none"
style={{
width: canvasSize.width,
height: canvasSize.height,
}}
/>
</div>
);
};
export default FlickeringGrid;