IMPROVED - Add custom macOS-style squircle app icons to the dock

This commit is contained in:
moh
2026-09-20 19:05:29 +02:00
parent 6543ccada3
commit 6e7cc4dbc6
3 changed files with 142 additions and 32 deletions
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { useId } from "react";
export type DockSymbol = "home" | "about" | "portfolio" | "products" | "contact";
/**
* macOS Big Surstyle squircle app icon, drawn entirely in SVG so it stays
* crisp at every magnification step. Inactive icons use a neutral graphite
* gradient; the active icon lights up in the brand coral.
*/
export function DockAppIcon({
symbol,
active = false,
disabled = false,
}: {
symbol: DockSymbol;
active?: boolean;
disabled?: boolean;
}) {
const uid = useId().replace(/[:]/g, "");
const bg = `bg-${uid}`;
const gloss = `gloss-${uid}`;
return (
<svg
viewBox="0 0 100 100"
className="h-full w-full"
role="presentation"
style={disabled ? { opacity: 0.55 } : undefined}
>
<defs>
<linearGradient id={bg} x1="0" y1="0" x2="0" y2="1">
{active ? (
<>
<stop offset="0" stopColor="#ff8a5f" />
<stop offset="1" stopColor="#e5431f" />
</>
) : (
<>
<stop offset="0" stopColor="#454b57" />
<stop offset="1" stopColor="#1c2027" />
</>
)}
</linearGradient>
<linearGradient id={gloss} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#ffffff" stopOpacity="0.28" />
<stop offset="0.5" stopColor="#ffffff" stopOpacity="0" />
</linearGradient>
</defs>
{/* tile */}
<rect x="4" y="4" width="92" height="92" rx="26" fill={`url(#${bg})`} />
<rect x="4" y="4" width="92" height="92" rx="26" fill={`url(#${gloss})`} />
<rect
x="4.75"
y="4.75"
width="90.5"
height="90.5"
rx="25.25"
fill="none"
stroke="#ffffff"
strokeOpacity="0.14"
strokeWidth="1.5"
/>
{/* symbol */}
<g
fill="none"
stroke="#ffffff"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
>
{symbol === "home" && (
<>
<path d="M29 49 L50 30 L71 49" />
<path d="M34 46 V70 H66 V46" />
</>
)}
{symbol === "about" && (
<>
<circle cx="50" cy="41" r="11" />
<path d="M30 72 C30 57, 70 57, 70 72" />
</>
)}
{symbol === "contact" && (
<>
<rect x="27" y="35" width="46" height="30" rx="6" />
<path d="M30 40 L50 54 L70 40" />
</>
)}
</g>
{/* filled symbols */}
{symbol === "portfolio" && (
<g fill="#ffffff">
<rect x="32" y="32" width="15" height="15" rx="4" />
<rect x="53" y="32" width="15" height="15" rx="4" />
<rect x="32" y="53" width="15" height="15" rx="4" />
<rect x="53" y="53" width="15" height="15" rx="4" />
</g>
)}
{symbol === "products" && (
<g
fill="none"
stroke="#ffffff"
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M50 28 L71 40 L71 60 L50 72 L29 60 L29 40 Z" />
<path d="M29 40 L50 52 L71 40" />
<path d="M50 52 L50 72" />
</g>
)}
</svg>
);
}
+22 -31
View File
@@ -1,13 +1,14 @@
"use client";
import { FolderKanban, Home, Mail, Package, ShieldCheck, User } from "lucide-react";
import { ShieldCheck } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState, type ComponentType } from "react";
import { useEffect, useState } from "react";
import { DockAppIcon, type DockSymbol } from "@/components/layout/dock-app-icon";
import { LocaleToggle } from "@/components/layout/locale-toggle";
import { SoundToggle } from "@/components/sound-toggle";
import { ThemeToggle } from "@/components/theme-toggle";
@@ -30,18 +31,17 @@ type SiteDockProps = {
};
type NavItem = {
key: "home" | "about" | "portfolio" | "products" | "contact";
key: DockSymbol;
path: string;
Icon: ComponentType<{ className?: string }>;
disabled?: boolean;
};
const navItems: NavItem[] = [
{ key: "home", path: "", Icon: Home },
{ key: "about", path: "/about", Icon: User },
{ key: "portfolio", path: "/portfolio", Icon: FolderKanban },
{ key: "products", path: "/products", Icon: Package, disabled: true },
{ key: "contact", path: "/contact", Icon: Mail },
{ key: "home", path: "" },
{ key: "about", path: "/about" },
{ key: "portfolio", path: "/portfolio" },
{ key: "products", path: "/products", disabled: true },
{ key: "contact", path: "/contact" },
];
function isNavItemActive(currentPath: string, itemPath: string) {
@@ -192,7 +192,7 @@ export function SiteDock({
className="pointer-events-auto h-[68px] gap-2.5 border-border/60 bg-background/70 shadow-panel"
>
{/* Logo — first icon in the dock */}
<DockIcon className="group relative overflow-visible rounded-[28%] border border-border/60 bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.5)]">
<DockIcon className="group relative overflow-visible rounded-[26%] bg-white shadow-[0_6px_14px_-6px_rgba(0,0,0,0.5)] ring-1 ring-black/10">
<Link
href={homeHref}
aria-label="mohfarawati — Home"
@@ -206,7 +206,7 @@ export function SiteDock({
sizes="96px"
priority
className={cn(
"h-full w-full rounded-full object-contain transition-opacity duration-300",
"h-full w-full rounded-[inherit] object-contain transition-opacity duration-300",
mounted ? "opacity-100" : "opacity-0",
)}
/>
@@ -216,38 +216,29 @@ export function SiteDock({
<div className="mx-0.5 h-9 w-px self-center bg-border/70" aria-hidden />
{navItems.map(({ key, path, Icon, disabled }) => {
{navItems.map(({ key, path, disabled }) => {
const itemPath = path || "/";
const isActive = isNavItemActive(currentPath, itemPath);
const label = t(key);
const inner = (
<span className="flex h-full w-full items-center justify-center rounded-[inherit]">
<Icon className="h-[72%] w-[72%]" strokeWidth={1.9} />
</span>
);
const icon = <DockAppIcon symbol={key} active={isActive} disabled={disabled} />;
return (
<DockIcon
key={key}
className={cn(
"group relative overflow-visible rounded-[28%] border transition-all duration-200",
"shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]",
"group relative overflow-visible rounded-[26%] transition-shadow duration-200",
isActive
? "border-primary/50 bg-primary/15 text-primary shadow-[inset_0_1px_0_rgba(255,255,255,0.15),0_0_0_1px_hsl(var(--primary)/0.35),0_10px_24px_-8px_hsl(var(--primary)/0.6)]"
: disabled
? "border-border/50 bg-foreground/[0.03] text-foreground/35"
: "border-border/60 bg-foreground/[0.05] text-foreground/75 hover:border-border hover:bg-foreground/[0.09] hover:text-foreground",
? "shadow-[0_12px_26px_-8px_hsl(var(--primary)/0.65)]"
: "shadow-[0_6px_14px_-6px_rgba(0,0,0,0.5)] hover:shadow-[0_10px_22px_-8px_rgba(0,0,0,0.6)]",
)}
>
{/* top gloss */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] bg-gradient-to-b from-white/10 to-transparent"
/>
{disabled ? (
<span aria-disabled="true" className="flex h-full w-full cursor-not-allowed items-center justify-center">
{inner}
<span
aria-disabled="true"
className="flex h-full w-full cursor-not-allowed items-center justify-center rounded-[inherit]"
>
{icon}
</span>
) : (
<Link
@@ -256,7 +247,7 @@ export function SiteDock({
aria-current={isActive ? "page" : undefined}
className="flex h-full w-full items-center justify-center rounded-[inherit]"
>
{inner}
{icon}
</Link>
)}
+1 -1
View File
@@ -107,7 +107,7 @@ const DockIcon = ({
...props
}: DockIconProps) => {
const ref = useRef<HTMLDivElement>(null);
const padding = Math.max(4, size * 0.12);
const padding = Math.max(2, size * 0.05);
const defaultMouseX = useMotionValue(Infinity);
const distanceCalc = useTransform(mouseX ?? defaultMouseX, (val: number) => {