74 lines
3.3 KiB
TypeScript
74 lines
3.3 KiB
TypeScript
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;
|