IMPROVED - Add custom macOS-style squircle app icons to the dock
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
export type DockSymbol = "home" | "about" | "portfolio" | "products" | "contact";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* macOS Big Sur–style 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FolderKanban, Home, Mail, Package, ShieldCheck, User } from "lucide-react";
|
import { ShieldCheck } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
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 { LocaleToggle } from "@/components/layout/locale-toggle";
|
||||||
import { SoundToggle } from "@/components/sound-toggle";
|
import { SoundToggle } from "@/components/sound-toggle";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
@@ -30,18 +31,17 @@ type SiteDockProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
key: "home" | "about" | "portfolio" | "products" | "contact";
|
key: DockSymbol;
|
||||||
path: string;
|
path: string;
|
||||||
Icon: ComponentType<{ className?: string }>;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ key: "home", path: "", Icon: Home },
|
{ key: "home", path: "" },
|
||||||
{ key: "about", path: "/about", Icon: User },
|
{ key: "about", path: "/about" },
|
||||||
{ key: "portfolio", path: "/portfolio", Icon: FolderKanban },
|
{ key: "portfolio", path: "/portfolio" },
|
||||||
{ key: "products", path: "/products", Icon: Package, disabled: true },
|
{ key: "products", path: "/products", disabled: true },
|
||||||
{ key: "contact", path: "/contact", Icon: Mail },
|
{ key: "contact", path: "/contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isNavItemActive(currentPath: string, itemPath: string) {
|
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"
|
className="pointer-events-auto h-[68px] gap-2.5 border-border/60 bg-background/70 shadow-panel"
|
||||||
>
|
>
|
||||||
{/* Logo — first icon in the dock */}
|
{/* 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
|
<Link
|
||||||
href={homeHref}
|
href={homeHref}
|
||||||
aria-label="mohfarawati — Home"
|
aria-label="mohfarawati — Home"
|
||||||
@@ -206,7 +206,7 @@ export function SiteDock({
|
|||||||
sizes="96px"
|
sizes="96px"
|
||||||
priority
|
priority
|
||||||
className={cn(
|
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",
|
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 />
|
<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 itemPath = path || "/";
|
||||||
const isActive = isNavItemActive(currentPath, itemPath);
|
const isActive = isNavItemActive(currentPath, itemPath);
|
||||||
const label = t(key);
|
const label = t(key);
|
||||||
|
|
||||||
const inner = (
|
const icon = <DockAppIcon symbol={key} active={isActive} disabled={disabled} />;
|
||||||
<span className="flex h-full w-full items-center justify-center rounded-[inherit]">
|
|
||||||
<Icon className="h-[72%] w-[72%]" strokeWidth={1.9} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DockIcon
|
<DockIcon
|
||||||
key={key}
|
key={key}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative overflow-visible rounded-[28%] border transition-all duration-200",
|
"group relative overflow-visible rounded-[26%] transition-shadow duration-200",
|
||||||
"shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]",
|
|
||||||
isActive
|
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)]"
|
? "shadow-[0_12px_26px_-8px_hsl(var(--primary)/0.65)]"
|
||||||
: disabled
|
: "shadow-[0_6px_14px_-6px_rgba(0,0,0,0.5)] hover:shadow-[0_10px_22px_-8px_rgba(0,0,0,0.6)]",
|
||||||
? "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",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* top gloss */}
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute inset-0 rounded-[inherit] bg-gradient-to-b from-white/10 to-transparent"
|
|
||||||
/>
|
|
||||||
{disabled ? (
|
{disabled ? (
|
||||||
<span aria-disabled="true" className="flex h-full w-full cursor-not-allowed items-center justify-center">
|
<span
|
||||||
{inner}
|
aria-disabled="true"
|
||||||
|
className="flex h-full w-full cursor-not-allowed items-center justify-center rounded-[inherit]"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
@@ -256,7 +247,7 @@ export function SiteDock({
|
|||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className="flex h-full w-full items-center justify-center rounded-[inherit]"
|
className="flex h-full w-full items-center justify-center rounded-[inherit]"
|
||||||
>
|
>
|
||||||
{inner}
|
{icon}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ const DockIcon = ({
|
|||||||
...props
|
...props
|
||||||
}: DockIconProps) => {
|
}: DockIconProps) => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
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 defaultMouseX = useMotionValue(Infinity);
|
||||||
|
|
||||||
const distanceCalc = useTransform(mouseX ?? defaultMouseX, (val: number) => {
|
const distanceCalc = useTransform(mouseX ?? defaultMouseX, (val: number) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user