Redesign im Stil von crispmtl.com: minimalistisch, Foto-Hero, weniger CTAs
- Hell mit feinen Rasterlinien, breite Versal-Headlines (Archivo Expanded), kleine Serifen-Labels, eckige Buttons mit Füll-/Text-Roll-Hover - Hero: Foto aus dem Salon statt animiertem Hintergrund, eine Headline, ein Button; "Termin buchen" oben rechts nur noch als Textlink - Chrom-Wortmarke, Seidenhintergrund, Laufband, Kreis-Badge, großer Schluss-CTA und mobile Buchungsleiste entfernt; deutlich weniger Text - Neue interaktive Elemente: Menü-Overlay mit Bildwechsel pro Link, Team als Namensliste mit Porträt-Wisch-Übergang, Preise als Akkordeon mit Team-Thumbnails, Footer mit Fotoraster zwischen den Wörtern - Bilder aus den echten Salon-Fotos (Logo-Wand), neues Favicon/OG-Bild Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
This commit is contained in:
49 files changed
+815
-1494
No files matched your search
+49
-73
@@ -1,39 +1,44 @@
|
||||
// Text-Reveals im Stil von „Split Text“ / „Blur Text“ aus React Bits (reactbits.dev).
|
||||
import { motion, type Variants } from 'motion/react'
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../lib/cn'
|
||||
import { EASE_OUT_EXPO } from '../lib/motion'
|
||||
|
||||
const tags = { h1: motion.h1, h2: motion.h2, h3: motion.h3, p: motion.p, div: motion.div }
|
||||
|
||||
const lineVariants: Variants = {
|
||||
hidden: { y: '110%' },
|
||||
hidden: { y: '105%' },
|
||||
show: { y: '0%', transition: { duration: 1.1, ease: EASE_OUT_EXPO } },
|
||||
}
|
||||
|
||||
/** Zeilen gleiten nacheinander aus einer Maske nach oben — ausgelöst beim Scrollen. */
|
||||
/** Zeilen gleiten nacheinander aus einer Maske nach oben — beim Scrollen oder direkt beim Laden. */
|
||||
export function RevealLines({
|
||||
lines,
|
||||
as = 'h2',
|
||||
className,
|
||||
delay = 0,
|
||||
onLoad = false,
|
||||
srLabel,
|
||||
}: {
|
||||
lines: ReactNode[]
|
||||
as?: keyof typeof tags
|
||||
className?: string
|
||||
delay?: number
|
||||
onLoad?: boolean
|
||||
/** Text für Screenreader, wenn die sichtbaren Zeilen nur ein Ausschnitt sind */
|
||||
srLabel?: string
|
||||
}) {
|
||||
const Tag = tags[as]
|
||||
const trigger = onLoad ? { animate: 'show' } : { whileInView: 'show', viewport: { once: true, amount: 0.5 } }
|
||||
return (
|
||||
<Tag
|
||||
className={className}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, amount: 0.5 }}
|
||||
{...trigger}
|
||||
variants={{ hidden: {}, show: { transition: { staggerChildren: 0.09, delayChildren: delay } } }}
|
||||
>
|
||||
{srLabel && <span className="sr-only">{srLabel}</span>}
|
||||
{lines.map((line, i) => (
|
||||
<span key={i} className="-mb-[0.12em] block overflow-hidden pr-[0.08em] pb-[0.12em]">
|
||||
<span key={i} aria-hidden={srLabel ? true : undefined} className="-mb-[0.08em] block overflow-hidden pb-[0.08em]">
|
||||
<motion.span className="block" variants={lineVariants}>
|
||||
{line}
|
||||
</motion.span>
|
||||
@@ -43,75 +48,13 @@ export function RevealLines({
|
||||
)
|
||||
}
|
||||
|
||||
/** Einzelne Buchstaben steigen gestaffelt auf — für die große Wortmarke im Hero. */
|
||||
export function RevealChars({
|
||||
words,
|
||||
className,
|
||||
charClassName,
|
||||
delay = 0.15,
|
||||
stagger = 0.035,
|
||||
}: {
|
||||
/** `mobileBreak`: auf kleinen Bildschirmen nach diesem Wort umbrechen */
|
||||
words: { text: string; className?: string; mobileBreak?: boolean }[]
|
||||
className?: string
|
||||
/** wird auf jeden Buchstaben angewendet (z. B. Verlauf per background-clip) */
|
||||
charClassName?: string
|
||||
delay?: number
|
||||
stagger?: number
|
||||
}) {
|
||||
let index = 0
|
||||
return (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className={cn('inline-block whitespace-nowrap', className)}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={{ hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } } }}
|
||||
>
|
||||
{words.map((word, w) => (
|
||||
<Fragment key={w}>
|
||||
<span className={cn('inline-block', word.className)}>
|
||||
{Array.from(word.text).map((char) => (
|
||||
<span key={index++} className="-mb-[0.1em] inline-block overflow-hidden pb-[0.1em] align-top">
|
||||
<motion.span
|
||||
className={cn('inline-block', charClassName)}
|
||||
variants={{
|
||||
hidden: { y: '105%', rotate: 6 },
|
||||
show: { y: '0%', rotate: 0, transition: { duration: 1.2, ease: EASE_OUT_EXPO } },
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{w < words.length - 1 && (
|
||||
<span className={cn('w-[0.2em]', word.mobileBreak ? 'hidden md:inline-block' : 'inline-block')} />
|
||||
)}
|
||||
{word.mobileBreak && <br className="md:hidden" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</motion.span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Sanftes Einblenden mit Unschärfe beim Scrollen. */
|
||||
export function FadeIn({
|
||||
children,
|
||||
className,
|
||||
delay = 0,
|
||||
y = 24,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
delay?: number
|
||||
y?: number
|
||||
}) {
|
||||
/** Sanftes Einblenden beim Scrollen. */
|
||||
export function FadeIn({ children, className, delay = 0 }: { children: ReactNode; className?: string; delay?: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial={{ opacity: 0, y, filter: 'blur(8px)' }}
|
||||
whileInView={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
transition={{ duration: 1, delay, ease: EASE_OUT_EXPO }}
|
||||
>
|
||||
@@ -119,3 +62,36 @@ export function FadeIn({
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bild, das sich von unten aufdeckt und dabei leicht herauszoomt. */
|
||||
export function ImageReveal({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
imgClassName,
|
||||
}: {
|
||||
src: string
|
||||
alt: string
|
||||
className?: string
|
||||
imgClassName?: string
|
||||
}) {
|
||||
const transition = { duration: 1.3, ease: EASE_OUT_EXPO }
|
||||
// Beobachtet wird der ungeclippte Rahmen: ein komplett weggeclipptes Element gilt nie als sichtbar
|
||||
return (
|
||||
<motion.div className={className} initial="hidden" whileInView="show" viewport={{ once: true, amount: 0.25 }}>
|
||||
<motion.div
|
||||
className="size-full overflow-hidden"
|
||||
variants={{ hidden: { clipPath: 'inset(100% 0% 0% 0%)' }, show: { clipPath: 'inset(0% 0% 0% 0%)', transition } }}
|
||||
>
|
||||
<motion.img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={cn('size-full object-cover', imgClassName)}
|
||||
variants={{ hidden: { scale: 1.2 }, show: { scale: 1, transition: { ...transition, duration: 1.7 } } }}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user