Files
Anelia-Wolf/src/components/ui/Button.tsx
T
2026-09-06 17:21:52 +02:00

74 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cn } from "@/lib/utils";
import { TransitionLink } from "@/components/motion/TransitionLink";
export interface ButtonProps {
href: string;
label: string;
/** Mono suffix that rises out of a mask inside the button on hover (≥ 1024); its width is reserved. */
suffix?: string;
/** Mono micro line under the button, e.g. „Telefonisch oder online · Deutsch oder Englisch“. */
micro?: string;
/** `primary` = 1 px accent outline + gold sheen fill on hover; `ghost` = neutral hairline, no fill. */
variant?: "primary" | "ghost";
/** `target="_blank" rel="noopener noreferrer"`. */
external?: boolean;
className?: string;
}
export const BUTTON_BASE =
"group/btn relative inline-flex items-center overflow-hidden whitespace-nowrap border px-[2.1em] py-[1.05em] font-sans text-[0.8125rem] font-medium uppercase leading-none tracking-[0.2em] text-fg transition-[color,background-color,border-color] duration-300";
/* Sheen fill: --gold-sheen at 250 % width, background-position 0 % → 100 % in .6 s. Text turns ink. */
export const BUTTON_PRIMARY =
"border-accent hover:text-ink focus-visible:text-ink before:pointer-events-none before:absolute before:inset-0 before:bg-[image:var(--gold-sheen)] before:bg-[length:250%_100%] before:bg-[position:0%_50%] before:opacity-0 before:transition-[opacity,background-position] before:duration-[600ms] before:ease-[var(--ease-out-quart)] hover:before:bg-[position:100%_50%] hover:before:opacity-100 focus-visible:before:bg-[position:100%_50%] focus-visible:before:opacity-100";
const GHOST = "border-line hover:border-accent focus-visible:border-accent";
function isInternal(href: string) {
return href.startsWith("/");
}
/**
* Outline button (radius 0, no shadow). Internal routes use the curtain
* (`TransitionLink`); mailto/tel/http render a plain `<a>`.
*/
export function Button({ href, label, suffix, micro, variant = "primary", external = false, className }: ButtonProps) {
const cls = cn(BUTTON_BASE, variant === "primary" ? BUTTON_PRIMARY : GHOST, className);
const inner = (
<>
<span className="relative z-10">{label}</span>
{suffix && (
/* The mask sits INSIDE the button and its width is reserved, so the
control never resizes under the pointer – only the line rises. */
<span aria-hidden="true" className="relative z-10 hidden overflow-hidden lg:block">
<span className="block translate-y-full whitespace-nowrap pl-3 font-mono text-[11px] normal-case leading-[1.6] tracking-[0.06em] transition-transform duration-[350ms] ease-[var(--ease-out-quart)] group-hover/btn:translate-y-0 group-focus-visible/btn:translate-y-0">
{suffix}
</span>
</span>
)}
</>
);
const link =
isInternal(href) && !external ? (
<TransitionLink href={href} label={label} className={cls}>
{inner}
</TransitionLink>
) : (
<a href={href} target={external ? "_blank" : undefined} rel={external ? "noopener noreferrer" : undefined} className={cls}>
{inner}
</a>
);
if (!micro) return link;
return (
<span className="inline-flex flex-col items-start gap-3">
{link}
<span className="font-mono text-[11px] tracking-[0.06em] text-fg-muted">{micro}</span>
</span>
);
}
export default Button;