feat: UI polish — SolutionSection stacked cards, BeamButton CTA, Footer redesign

- SolutionSection: replace flat cards with interactive stacked card layout
  (skew, grayscale, hover lift, click-to-expand with z-50 + skew reset)
- BeamButton: new component with framer-motion SVG beam animation along
  pill border (emerald→blue gradient); variants dark/light
- HeroSection + FinalCTASection: primary CTA switched to BeamButton
- Footer: replace programmatic SVG logo with logo-axil.png via next/image;
  add AImpress LTD credit link; increase hero text height
- TextHoverEffect: fix viewBox (920×100), use SVG-unit fontSize, split
  AXIL (blue #1B9AD6) / ACCOUNTANTS (emerald #3CC68A) via tspan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-02-23 12:30:50 +00:00
parent 83a8878f4a
commit 7cd698d6d6
7 changed files with 297 additions and 86 deletions

View file

@ -30,7 +30,8 @@
"Bash(\"/Volumes/SSD/Projects/Clients/Axil Accountants/eslint.config.mjs\":*)",
"Bash(node_modules/.bin/eslint:*)",
"Bash(node --input-type=module:*)",
"Bash(python3:*)"
"Bash(python3:*)",
"Bash(ls:*)"
]
}
}

View file

@ -1,4 +1,5 @@
import Link from 'next/link';
import Image from 'next/image';
import { TextHoverEffect, FooterBackgroundGradient } from '@/components/ui/TextHoverEffect';
const SERVICES = [
@ -54,7 +55,7 @@ export function Footer() {
<FooterBackgroundGradient />
{/* TextHoverEffect brand name */}
<div className="relative z-10 flex h-28 items-center justify-center border-b border-white/8 sm:h-36">
<div className="relative z-10 flex h-36 items-center justify-center border-b border-white/8 sm:h-48">
<TextHoverEffect text="AXIL ACCOUNTANTS" duration={0} />
</div>
@ -63,28 +64,14 @@ export function Footer() {
<div className="grid grid-cols-2 gap-10 py-14 sm:grid-cols-4">
{/* Brand column */}
<div className="col-span-2 sm:col-span-1">
{/* SVG logo — light version for dark background */}
<div className="mb-4 flex items-center gap-2">
<svg width="52" height="32" viewBox="0 0 76 44" fill="none" aria-hidden="true">
<rect x="0" y="2" width="38" height="40" rx="5" fill="#1B9AD6" />
<path d="M19 6 L34 40 H4 L19 6Z" fill="white" />
<path d="M19 14 L27 32 H11 L19 14Z" fill="#1B9AD6" />
<rect x="5" y="33" width="28" height="6" rx="1.5" fill="#3CC68A" />
<rect x="45" y="30" width="7" height="14" rx="2" fill="#3CC68A" />
<rect x="56" y="22" width="7" height="22" rx="2" fill="#3CC68A" />
<rect x="67" y="11" width="7" height="33" rx="2" fill="#3CC68A" />
</svg>
<div className="flex flex-col leading-none">
<div className="flex items-baseline gap-1">
<span className="font-display text-blue text-base font-black tracking-[0.08em]">
AXIL
</span>
<span className="font-display text-emerald text-base font-bold tracking-[0.04em]">
ACCOUNTANTS
</span>
</div>
<div className="bg-blue/50 mt-0.5 h-[1px] rounded-full" />
</div>
<div className="mb-5">
<Image
src="/logo-axil.png"
alt="Axil Accountants"
width={140}
height={97}
className="h-10 w-auto"
/>
</div>
<p className="mb-6 max-w-[220px] text-sm leading-relaxed text-white/60">
@ -125,10 +112,23 @@ export function Footer() {
</div>
{/* Bottom bar */}
<div className="flex flex-col items-center justify-between gap-4 border-t border-white/10 py-6 sm:flex-row">
<div className="flex flex-col items-center justify-between gap-3 border-t border-white/10 py-6 sm:flex-row">
<p className="text-xs text-white/40">
© {new Date().getFullYear()} Axil Accountants Ltd. All rights reserved.
</p>
<p className="text-xs text-white/30">
Developed by{' '}
<a
href="https://ai-impress.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-emerald text-white/50 transition-colors"
>
AImpress LTD
</a>
</p>
<p className="text-xs text-white/40">
ICAEW Member · ACCA Certified · Registered in England & Wales
</p>

View file

@ -1,5 +1,6 @@
import { FadeIn } from '@/components/ui/FadeIn';
import { Button } from '@/components/ui/Button';
import { BeamButton } from '@/components/ui/BeamButton';
import { CheckCircleIcon } from '@/components/ui/icons';
const REASSURANCES = [
@ -35,13 +36,9 @@ export function FinalCTASection() {
</p>
<div className="mb-10 flex flex-wrap items-center justify-center gap-4">
<Button
size="lg"
className="text-charcoal hover:bg-emerald-mist bg-white"
trailingArrow
>
<BeamButton size="lg" variant="light" trailingArrow>
Book a Free Consultation
</Button>
</BeamButton>
<Button
size="lg"
variant="secondary"

View file

@ -2,6 +2,7 @@
import { motion } from 'framer-motion';
import { Button } from '@/components/ui/Button';
import { BeamButton } from '@/components/ui/BeamButton';
import { StarRating } from '@/components/ui/StarRating';
import { CheckCircleIcon } from '@/components/ui/icons';
@ -162,9 +163,9 @@ export function HeroSection() {
</p>
<div className="mb-7 flex flex-wrap gap-3">
<Button size="lg" trailingArrow>
<BeamButton size="lg" trailingArrow>
Book a Free Consultation
</Button>
</BeamButton>
<Button size="lg" variant="secondary">
See Our Services
</Button>

View file

@ -1,3 +1,6 @@
'use client';
import { useState } from 'react';
import { FadeIn } from '@/components/ui/FadeIn';
import { CheckCircleIcon } from '@/components/ui/icons';
@ -26,11 +29,32 @@ const CHECKLIST = [
'Real-time financial dashboard',
];
// Per-card: stack position + whether to show grayscale overlay in idle state
const STACK = [
{
pos: '',
hover: 'hover:-translate-y-10',
idle: "before:absolute before:inset-0 before:z-10 before:rounded-xl before:content-[''] before:bg-bg/50 before:transition-opacity before:duration-500 grayscale",
},
{
pos: 'translate-x-20 translate-y-12',
hover: 'hover:-translate-y-2',
idle: "before:absolute before:inset-0 before:z-10 before:rounded-xl before:content-[''] before:bg-bg/50 before:transition-opacity before:duration-500 grayscale",
},
{
pos: 'translate-x-40 translate-y-24',
hover: 'hover:translate-y-6',
idle: '',
},
];
export function SolutionSection() {
const [active, setActive] = useState<number | null>(null);
return (
<section className="bg-bg py-24 lg:py-32">
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 xl:px-16">
<div className="grid grid-cols-1 items-start gap-16 lg:grid-cols-2">
<div className="grid grid-cols-1 items-center gap-16 lg:grid-cols-2">
{/* Left — text */}
<FadeIn x={-20} y={0}>
<p className="text-emerald mb-4 text-sm font-semibold tracking-widest uppercase">
@ -56,22 +80,47 @@ export function SolutionSection() {
</ul>
</FadeIn>
{/* Right — numbered features */}
<FadeIn x={20} y={0} delay={0.15}>
<div className="rounded-card flex flex-col divide-y divide-black/6 border border-black/7 bg-white">
{FEATURES.map((f) => (
<div key={f.title} className="flex gap-5 p-7">
<span className="text-emerald/60 mt-0.5 shrink-0 font-mono text-sm font-medium">
{f.number}
</span>
<div>
<h3 className="font-display text-charcoal mb-1.5 text-base font-semibold">
{f.title}
</h3>
<p className="text-muted text-sm leading-relaxed">{f.body}</p>
{/* Right — stacked feature cards */}
<FadeIn x={20} y={0} delay={0.15} className="flex items-center justify-center py-24">
<div className="grid place-items-center [grid-template-areas:'stack']">
{FEATURES.map((f, i) => {
const isActive = active === i;
const isDimmed = active !== null && !isActive;
const { pos, hover, idle } = STACK[i];
return (
<div
key={f.number}
onClick={() => setActive(isActive ? null : i)}
className={[
'[grid-area:stack]',
pos,
'relative flex h-56 w-[30rem] cursor-pointer flex-col justify-between rounded-xl border px-8 py-6 transition-all duration-500 select-none',
isActive
? 'border-emerald/30 z-50 skew-y-0 bg-white shadow-2xl'
: [
'-skew-y-[8deg] border-black/7 bg-white/90',
hover,
"after:from-bg after:absolute after:top-[-5%] after:-right-1 after:h-[110%] after:w-80 after:bg-gradient-to-l after:to-transparent after:content-['']",
idle,
].join(' '),
isDimmed ? 'opacity-40' : '',
]
.join(' ')
.trim()}
>
<div className="flex items-center gap-2.5">
<span className="text-emerald/60 font-mono text-sm font-medium">
{f.number}
</span>
<h3 className="font-display text-charcoal text-lg font-semibold">
{f.title}
</h3>
</div>
<p className="text-muted text-base leading-relaxed">{f.body}</p>
</div>
</div>
))}
);
})}
</div>
</FadeIn>
</div>

View file

@ -0,0 +1,144 @@
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { ArrowRightIcon } from './icons';
import { Spinner } from './Spinner';
type Size = 'sm' | 'md' | 'lg';
type Variant = 'dark' | 'light';
interface BeamButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: Size;
variant?: Variant;
loading?: boolean;
trailingArrow?: boolean;
leadingIcon?: React.ReactNode;
}
const HEIGHTS: Record<Size, number> = { sm: 36, md: 44, lg: 56 };
const PADDINGS: Record<Size, string> = {
sm: 'px-4 text-sm',
md: 'px-6 text-base',
lg: 'px-8 text-lg',
};
const GAPS: Record<Size, string> = { sm: 'gap-1.5', md: 'gap-2', lg: 'gap-2.5' };
const VARIANTS: Record<Variant, { bg: string; text: string; border: string }> = {
dark: {
bg: 'bg-charcoal hover:bg-charcoal/85',
text: 'text-white',
border: 'rgba(255,255,255,0.1)',
},
light: {
bg: 'bg-white hover:bg-emerald-mist',
text: 'text-charcoal',
border: 'rgba(22,37,32,0.1)',
},
};
export function BeamButton({
children,
size = 'md',
variant = 'dark',
loading = false,
trailingArrow = false,
leadingIcon,
className = '',
disabled,
...props
}: BeamButtonProps) {
const uid = useId().replace(/:/g, '');
const ref = useRef<HTMLButtonElement>(null);
const [w, setW] = useState(180);
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => setW(Math.round(e.contentRect.width)));
ro.observe(el);
return () => ro.disconnect();
}, []);
const h = HEIGHTS[size];
// Perimeter of pill: two straight edges + one full circle (r = h/2)
const perimeter = 2 * (w - h) + Math.PI * h;
const beamLen = Math.min(perimeter * 0.28, 90);
const { bg, text, border } = VARIANTS[variant];
const arrowSize = size === 'sm' ? 14 : size === 'lg' ? 20 : 16;
return (
<button
ref={ref}
disabled={disabled || loading}
style={{ height: h }}
className={[
'rounded-pill relative inline-flex cursor-pointer items-center justify-center font-medium',
'transition-all duration-200 select-none',
'active:scale-[0.98]',
'focus-visible:ring-emerald/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100',
bg,
text,
PADDINGS[size],
GAPS[size],
className,
].join(' ')}
{...props}
>
{/* Beam border */}
<svg aria-hidden className="pointer-events-none absolute inset-0" width={w} height={h}>
{/* Static base border */}
<rect
x="1"
y="1"
width={w - 2}
height={h - 2}
rx={9999}
fill="none"
stroke={border}
strokeWidth="1.5"
/>
{/* Traveling beam */}
<motion.rect
x="1"
y="1"
width={w - 2}
height={h - 2}
rx={9999}
fill="none"
stroke={`url(#${uid})`}
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={`${beamLen} ${perimeter}`}
initial={{ strokeDashoffset: 0 }}
animate={{ strokeDashoffset: -(perimeter + beamLen) }}
transition={{
duration: 2.8,
repeat: Infinity,
ease: 'linear',
repeatDelay: 0.6,
}}
/>
<defs>
<linearGradient id={uid} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2={w} y2="0">
<stop offset="0%" stopColor="#3CC68A" stopOpacity="0" />
<stop offset="30%" stopColor="#3CC68A" stopOpacity="1" />
<stop offset="70%" stopColor="#1B9AD6" stopOpacity="1" />
<stop offset="100%" stopColor="#1B9AD6" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
{/* Content */}
{loading ? <Spinner size={size === 'lg' ? 'md' : 'sm'} /> : (leadingIcon ?? null)}
{children}
{!loading && trailingArrow && (
<ArrowRightIcon
size={arrowSize}
className="shrink-0 transition-transform duration-200 group-hover:translate-x-0.5"
/>
)}
</button>
);
}

View file

@ -20,46 +20,36 @@ export const TextHoverEffect = ({
const [maskPosition, setMaskPosition] = useState({ cx: '50%', cy: '50%' });
useEffect(() => {
if (svgRef.current && cursor.x !== null && cursor.y !== null) {
if (svgRef.current) {
const svgRect = svgRef.current.getBoundingClientRect();
const cxPercentage = ((cursor.x - svgRect.left) / svgRect.width) * 100;
const cyPercentage = ((cursor.y - svgRect.top) / svgRect.height) * 100;
setMaskPosition({
cx: `${cxPercentage}%`,
cy: `${cyPercentage}%`,
});
setMaskPosition({ cx: `${cxPercentage}%`, cy: `${cyPercentage}%` });
}
}, [cursor]);
// First word → blue (#1B9AD6), remaining → emerald (#3CC68A) — matches logo
const [firstWord, ...rest] = text.split(' ');
const secondWord = rest.join(' ');
return (
<svg
ref={svgRef}
width="100%"
height="100%"
viewBox="0 0 300 100"
viewBox="0 0 920 100"
xmlns="http://www.w3.org/2000/svg"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={(e) => setCursor({ x: e.clientX, y: e.clientY })}
className={cn('cursor-pointer uppercase select-none', className)}
className={cn('cursor-pointer select-none', className)}
>
<defs>
<linearGradient id="textGradient" gradientUnits="userSpaceOnUse" cx="50%" cy="50%" r="25%">
{hovered && (
<>
<stop offset="0%" stopColor="var(--color-emerald)" />
<stop offset="35%" stopColor="var(--color-blue)" />
<stop offset="65%" stopColor="var(--color-emerald)" />
<stop offset="100%" stopColor="var(--color-emerald-dark)" />
</>
)}
</linearGradient>
{/* @ts-expect-error — motion.radialGradient is valid framer-motion SVG element */}
<motion.radialGradient
id="revealMask"
gradientUnits="userSpaceOnUse"
r="20%"
r="22%"
initial={{ cx: '50%', cy: '50%' }}
animate={maskPosition}
transition={{ duration: duration ?? 0, ease: 'easeOut' }}
@ -73,47 +63,76 @@ export const TextHoverEffect = ({
</mask>
</defs>
{/* Stroke outline — visible on hover */}
{/* Base outlines — always visible at low opacity, correct brand colors */}
<text
x="50%"
y="50%"
y="52%"
textAnchor="middle"
dominantBaseline="middle"
strokeWidth="0.3"
className="fill-transparent stroke-white/20 font-[helvetica] text-7xl font-bold"
style={{ opacity: hovered ? 0.7 : 0 }}
fontSize={68}
fontFamily="'Satoshi', sans-serif"
fontWeight={700}
letterSpacing={3}
fill="transparent"
strokeWidth={0.5}
>
{text}
<tspan stroke="#1B9AD6" strokeOpacity={0.3}>
{firstWord}{' '}
</tspan>
<tspan stroke="#3CC68A" strokeOpacity={0.3}>
{secondWord}
</tspan>
</text>
{/* Animated draw-on stroke */}
<motion.text
x="50%"
y="50%"
y="52%"
textAnchor="middle"
dominantBaseline="middle"
strokeWidth="0.3"
className="fill-transparent font-[helvetica] text-7xl font-bold"
style={{ stroke: 'var(--color-emerald)' }}
fontSize={68}
fontFamily="'Satoshi', sans-serif"
fontWeight={700}
letterSpacing={3}
fill="transparent"
strokeWidth={0.6}
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
animate={{ strokeDashoffset: 0, strokeDasharray: 1000 }}
transition={{ duration: 4, ease: 'easeInOut' }}
>
{text}
<tspan stroke="#1B9AD6">{firstWord} </tspan>
<tspan stroke="#3CC68A">{secondWord}</tspan>
</motion.text>
{/* Colour reveal on mouse move */}
{/* Hover fill reveal under radial cursor mask */}
<text
x="50%"
y="50%"
y="52%"
textAnchor="middle"
dominantBaseline="middle"
stroke="url(#textGradient)"
strokeWidth="0.3"
fontSize={68}
fontFamily="'Satoshi', sans-serif"
fontWeight={700}
letterSpacing={3}
strokeWidth={0.5}
mask="url(#textMask)"
className="fill-transparent font-[helvetica] text-7xl font-bold"
>
{text}
<tspan
fill="#1B9AD6"
stroke="#1B9AD6"
fillOpacity={hovered ? 0.3 : 0}
style={{ transition: 'fill-opacity 0.3s' }}
>
{firstWord}{' '}
</tspan>
<tspan
fill="#3CC68A"
stroke="#3CC68A"
fillOpacity={hovered ? 0.3 : 0}
style={{ transition: 'fill-opacity 0.3s' }}
>
{secondWord}
</tspan>
</text>
</svg>
);
@ -124,7 +143,7 @@ export const FooterBackgroundGradient = () => (
className="absolute inset-0 z-0"
style={{
background:
'radial-gradient(125% 125% at 50% 10%, rgba(22,37,32,0.6) 50%, rgba(27,154,214,0.15) 100%)',
'radial-gradient(110% 110% at 50% 0%, rgba(22,37,32,0.8) 40%, rgba(27,154,214,0.1) 100%)',
}}
/>
);