feat(branding): implement hand-drawn Copic marker, strikethrough, and pen circle effects; redesign guarantee section with animated signature
This commit is contained in:
@@ -1,14 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useId, useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "../utils/cn";
|
||||
|
||||
/**
|
||||
* TECHNICAL MARKER COMPONENT
|
||||
* Implements the "hand-drawn marker" effect.
|
||||
* Animates in when entering the viewport.
|
||||
*/
|
||||
interface MarkerProps {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
@@ -16,74 +11,171 @@ interface MarkerProps {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copic Marker Component
|
||||
*
|
||||
* Uses hand-drawn filled polygon paths (NOT rects or strokes) for an
|
||||
* organic, human feel. Each highlight band is a wobbly shape with
|
||||
* bezier curves, not a perfect rectangle. Second strokes are offset
|
||||
* to simulate imperfect re-highlighting.
|
||||
*/
|
||||
|
||||
// Seeded PRNG for SSR-safe randomness
|
||||
function createRng(seed: string) {
|
||||
let s = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
s = ((s << 5) - s + seed.charCodeAt(i)) | 0;
|
||||
}
|
||||
return () => {
|
||||
s = (s * 16807 + 0) % 2147483647;
|
||||
return (s & 0x7fffffff) / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hand-drawn highlight band path.
|
||||
* Instead of a rectangle, we trace an organic polygon with wobbly edges.
|
||||
*
|
||||
* The shape goes: left edge → top edge (left to right) → right edge → bottom edge (right to left)
|
||||
* Each edge has 1-2 control points for subtle curves.
|
||||
*
|
||||
* ViewBox: 0 0 100 100 with preserveAspectRatio="none"
|
||||
* So x=0..100 maps to text width, y=0..100 maps to text height.
|
||||
*/
|
||||
function generateBandPath(rng: () => number, bandIndex: number) {
|
||||
// Vertical position: center band in the text height
|
||||
// First band centered around y=50, second shifted up or down
|
||||
const yOffset = bandIndex === 0 ? 0 : (rng() - 0.5) * 20; // -10 to +10 shift
|
||||
const yCenter = 50 + yOffset;
|
||||
|
||||
// Band thickness: covers ~40-50% of text height
|
||||
const halfHeight = 20 + rng() * 8; // 20-28 units (40-56% of viewBox)
|
||||
const yTop = yCenter - halfHeight;
|
||||
const yBottom = yCenter + halfHeight;
|
||||
|
||||
// Horizontal extent: slight random overshoot/undershoot
|
||||
const xStart = -2 + rng() * 3; // -2 to 1
|
||||
const xEnd = 99 + rng() * 3; // 99 to 102
|
||||
|
||||
// Generate wobbly top edge points (left to right)
|
||||
const topWobble1 = yTop + (rng() - 0.5) * 6;
|
||||
const topWobble2 = yTop + (rng() - 0.5) * 6;
|
||||
const topWobble3 = yTop + (rng() - 0.5) * 6;
|
||||
|
||||
// Generate wobbly bottom edge points (right to left)
|
||||
const botWobble1 = yBottom + (rng() - 0.5) * 6;
|
||||
const botWobble2 = yBottom + (rng() - 0.5) * 6;
|
||||
const botWobble3 = yBottom + (rng() - 0.5) * 6;
|
||||
|
||||
// Slight angle on left/right edges
|
||||
const leftTopY = yTop + (rng() - 0.5) * 4;
|
||||
const leftBotY = yBottom + (rng() - 0.5) * 4;
|
||||
const rightTopY = yTop + (rng() - 0.5) * 4;
|
||||
const rightBotY = yBottom + (rng() - 0.5) * 4;
|
||||
|
||||
// Build the path:
|
||||
// Start at top-left, trace top edge with curves, down right edge,
|
||||
// trace bottom edge with curves back left, close.
|
||||
const path = [
|
||||
// Start top-left
|
||||
`M ${xStart},${leftTopY}`,
|
||||
// Top edge: 3 curve segments left→right
|
||||
`C ${xStart + 15},${topWobble1} ${xStart + 30},${topWobble1} ${33},${topWobble2}`,
|
||||
`C ${45},${topWobble2} ${55},${topWobble3} ${67},${topWobble3}`,
|
||||
`C ${78},${topWobble3} ${xEnd - 10},${rightTopY} ${xEnd},${rightTopY}`,
|
||||
// Right edge down
|
||||
`L ${xEnd + (rng() - 0.5) * 2},${rightBotY}`,
|
||||
// Bottom edge: 3 curve segments right→left
|
||||
`C ${xEnd - 10},${botWobble1} ${78},${botWobble1} ${67},${botWobble2}`,
|
||||
`C ${55},${botWobble2} ${45},${botWobble3} ${33},${botWobble3}`,
|
||||
`C ${xStart + 30},${botWobble3} ${xStart + 15},${leftBotY} ${xStart},${leftBotY}`,
|
||||
// Close
|
||||
`Z`,
|
||||
].join(" ");
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export const Marker: React.FC<MarkerProps> = ({
|
||||
children,
|
||||
delay = 0,
|
||||
className = "",
|
||||
color = "rgba(255,235,59,0.7)",
|
||||
color = "rgba(250, 204, 21, 0.5)",
|
||||
}) => {
|
||||
const id = useId();
|
||||
const filterId = `marker-rough-${id.replace(/:/g, "")}`;
|
||||
|
||||
const bands = useMemo(() => {
|
||||
const rng = createRng(id);
|
||||
const numBands = rng() > 0.5 ? 2 : 1;
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < numBands; i++) {
|
||||
result.push({
|
||||
d: generateBandPath(rng, i),
|
||||
delay: delay + i * 0.15,
|
||||
duration: 0.4 + rng() * 0.15,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [id, delay]);
|
||||
|
||||
return (
|
||||
<span className={cn("relative inline px-1", className)}>
|
||||
<svg
|
||||
className="absolute inset-x-0 bottom-0 top-0 h-full w-full pointer-events-none z-[-1]"
|
||||
className="absolute inset-0 h-full w-full pointer-events-none z-[-1] overflow-visible mix-blend-multiply"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Organic Stroke 1: Main body */}
|
||||
<motion.path
|
||||
d="M 0,85 C 10,87 25,82 40,84 C 55,86 75,81 90,83 C 95,84 100,85 100,85"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
whileInView={{ pathLength: 1, opacity: 1 }}
|
||||
viewport={{ once: true, margin: "-5%" }}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
delay: delay + 0.1,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
}}
|
||||
stroke={color}
|
||||
strokeWidth="60"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
{/* Organic Stroke 2: Variation for overlap */}
|
||||
<motion.path
|
||||
d="M 5,82 C 20,80 40,85 60,82 C 80,79 95,84 100,83"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
whileInView={{ pathLength: 1, opacity: 0.6 }}
|
||||
viewport={{ once: true, margin: "-5%" }}
|
||||
transition={{
|
||||
duration: 1.8,
|
||||
delay: delay + 0.3,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
}}
|
||||
stroke={color}
|
||||
strokeWidth="35"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
{/* Organic Stroke 3: Rough edge details */}
|
||||
<motion.path
|
||||
d="M 0,88 C 15,90 35,85 55,87 C 75,89 90,84 100,86"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
whileInView={{ pathLength: 1, opacity: 0.4 }}
|
||||
viewport={{ once: true, margin: "-5%" }}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
delay: delay + 0.2,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
}}
|
||||
stroke={color}
|
||||
strokeWidth="15"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<defs>
|
||||
<filter id={filterId} x="-5%" y="-5%" width="110%" height="110%">
|
||||
{/* Very subtle edge wobble — alcohol ink bleed, not brush fraying */}
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.04 0.3"
|
||||
numOctaves="2"
|
||||
seed={Math.abs(id.charCodeAt(1) || 42)}
|
||||
result="noise"
|
||||
/>
|
||||
<feDisplacementMap
|
||||
in="SourceGraphic"
|
||||
in2="noise"
|
||||
scale="1.5"
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{bands.map((band, i) => (
|
||||
<motion.path
|
||||
key={i}
|
||||
d={band.d}
|
||||
fill={color}
|
||||
filter={`url(#${filterId})`}
|
||||
initial={{ scaleX: 0, opacity: 0 }}
|
||||
whileInView={{ scaleX: 1, opacity: 1 }}
|
||||
viewport={{ once: true, margin: "-10%" }}
|
||||
transition={{
|
||||
scaleX: {
|
||||
duration: band.duration,
|
||||
delay: band.delay,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
},
|
||||
opacity: {
|
||||
duration: 0.1,
|
||||
delay: band.delay,
|
||||
},
|
||||
}}
|
||||
style={{ transformOrigin: "left center" }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<span className="relative z-10 text-inherit">{children}</span>
|
||||
<span className="relative z-10 font-semibold" style={{ color: "#000" }}>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user