diff --git a/app/components/Cursors.tsx b/app/components/Cursors.tsx
new file mode 100644
index 0000000..1e74e6d
--- /dev/null
+++ b/app/components/Cursors.tsx
@@ -0,0 +1,102 @@
+import {
+ ArrowTopRightOnSquareIcon,
+ AtSymbolIcon,
+} from '@heroicons/react/24/outline';
+import { type Variant, motion } from 'framer-motion';
+import React from 'react';
+
+import { FadeIn, FadeInDirection } from '~/components/FadeIn';
+
+export type CustomCursor = Variant & { isRounded?: boolean };
+
+export function ContactUsVariant(): React.ReactNode {
+ return (
+
+ );
+}
+
+export function GithubVariant(): React.ReactNode {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Github{' '}
+
+
+
+
+
+ );
+}
+
+export function DribbbleVariant(): React.ReactNode {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ Dribbble{' '}
+
+
+
+
+
+ );
+}
diff --git a/app/context/CursorContext.tsx b/app/context/CursorContext.tsx
new file mode 100644
index 0000000..f374316
--- /dev/null
+++ b/app/context/CursorContext.tsx
@@ -0,0 +1,323 @@
+import type { CursorState } from 'ahooks/lib/useMouse';
+import clsx from 'clsx';
+import { motion } from 'framer-motion';
+import { get } from 'lodash-es';
+import { createContext } from 'react';
+import * as React from 'react';
+
+import type { CustomCursor } from '~/components/Cursors';
+import {
+ ContactUsVariant,
+ DribbbleVariant,
+ GithubVariant,
+} from '~/components/Cursors';
+import { FramerMotionIcon } from '~/icons/FramerMotionIcon';
+import { NestJsIcon } from '~/icons/NestJsIcon';
+import { ReactIcon } from '~/icons/ReactIcon';
+import { TailwindIcon } from '~/icons/TailwindIcon';
+import { TypescriptIcon } from '~/icons/TypescriptIcon';
+import { VueIcon } from '~/icons/VueIcon';
+import type { GrayscaleImageProps } from '~/lib/GrayscaleImage';
+
+export enum CursorVariants {
+ CONTACT = 'CONTACT',
+ CONTACT_CTA = 'CONTACT_CTA',
+ DEFAULT = 'DEFAULT',
+ DRIBBBLE = 'DRIBBBLE',
+ FRAMER_MOTION = 'FRAMER_MOTION',
+ GITHUB = 'GITHUB',
+ HIDDEN = 'HIDDEN',
+ IMG = 'IMG',
+ NEST_JS = 'NEST_JS',
+ REACT = 'REACT',
+ SHRUG = 'SHRUG',
+ TAILWIND = 'TAILWIND',
+ TYPESCRIPT = 'TYPESCRIPT',
+ VUE = 'VUE',
+}
+
+type CursorContext = {
+ cursorVariant: CursorVariants | GrayscaleImageProps;
+ setCursorVariant: (cursorVariant: CursorVariants) => void;
+};
+export const CursorContext = createContext({
+ cursorVariant: CursorVariants.DEFAULT,
+ setCursorVariant: (cursorVariant) => {
+ /**
+ * as we need a default action to handle the param
+ */
+ // eslint-disable-next-line no-console
+ console.debug({ cursorVariant });
+ },
+});
+
+const spring = {
+ type: 'spring',
+ stiffness: 500,
+ damping: 28,
+};
+
+export function CursorContextProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}): React.ReactNode {
+ const [cursorText, setCursorText] = React.useState('');
+
+ const [mouseCursorState, setMouseCursorState] = React.useState<
+ Partial
+ >({
+ clientX: 0,
+ clientY: 0,
+ });
+
+ const [cursorVariant, setCursorVariant] = React.useState(
+ CursorVariants.DEFAULT,
+ );
+
+ React.useEffect(() => {
+ window.addEventListener('mousemove', setMouseCursorState);
+
+ return () => {
+ window.removeEventListener('mousemove', setMouseCursorState);
+ };
+ }, []);
+
+ let mouseXPosition: number =
+ typeof window !== 'undefined'
+ ? Math.floor(get(window, ['innerWidth'], 0) / 2)
+ : 0;
+
+ let mouseYPosition = 0;
+
+ if (get(mouseCursorState, ['x']) !== null) {
+ mouseXPosition = get(mouseCursorState, ['clientX'], 0);
+ }
+
+ if (get(mouseCursorState, ['y']) !== null) {
+ mouseYPosition = get(mouseCursorState, ['clientY'], 0);
+ }
+
+ const isMouseAtStartPoint = [mouseXPosition, mouseYPosition].some(
+ (mousePosition) => mousePosition === 0,
+ );
+
+ const opacity: number = isMouseAtStartPoint ? 0 : 1;
+
+ const commonInteractiveCursor: CustomCursor = {
+ alignItems: 'center',
+ backgroundColor: '#dbfd39',
+ color: '#000',
+ display: 'flex',
+ height: 164,
+ justifyContent: 'center',
+ opacity,
+ width: 164,
+ x: mouseXPosition - 82,
+ y: mouseYPosition - 82,
+ };
+
+ const commonSocialCustomCursor: CustomCursor = {
+ borderRadius: '100%',
+ color: '#fdfdfd',
+ fontSize: '60px',
+ height: 96,
+ textAlign: 'center',
+ width: 96,
+ x: mouseXPosition - 48,
+ y: mouseYPosition - 48,
+ };
+
+ const cursorVariantMap: { [K in CursorVariants]: CustomCursor } = {
+ [CursorVariants.HIDDEN]: {
+ height: 0,
+ width: 0,
+ x: mouseXPosition,
+ y: mouseYPosition,
+ },
+ [CursorVariants.DEFAULT]: {
+ backgroundBlendMode: 'difference',
+ backgroundColor: 'rgba(245, 40, 145, 0.8)',
+ border: '1px solid papayawhip',
+ display: 'flex',
+ fontSize: '0',
+ height: 10,
+ mixBlendMode: 'difference',
+ opacity,
+ transition: { type: 'spring', mass: 0.6 },
+ width: 10,
+ x: mouseXPosition - 14,
+ y: mouseYPosition - 14,
+ },
+ [CursorVariants.CONTACT]: commonInteractiveCursor,
+ [CursorVariants.CONTACT_CTA]: {
+ ...commonInteractiveCursor,
+ height: 46,
+ justifyContent: 'center',
+ width: 160,
+ x: mouseXPosition - 160,
+ y: mouseYPosition - 20,
+ },
+ [CursorVariants.SHRUG]: {
+ backgroundBlendMode: 'difference',
+ backgroundColor: 'rgb(9,40,64)',
+ borderRadius: '100%',
+ color: '#fff',
+ fontSize: '24px',
+ height: 120,
+ opacity,
+ textAlign: 'center',
+ width: 120,
+ x: mouseXPosition - 60,
+ y: mouseYPosition - 140,
+ },
+ [CursorVariants.IMG]: {
+ backgroundColor: 'rgb(9,40,64)',
+ borderRadius: '28px',
+ display: 'flex',
+ height: 113,
+ opacity,
+ padding: '4px',
+ width: 200,
+ x: mouseXPosition - 50,
+ y: mouseYPosition - 100,
+ },
+ [CursorVariants.DRIBBBLE]: commonInteractiveCursor,
+ [CursorVariants.GITHUB]: commonInteractiveCursor,
+ [CursorVariants.NEST_JS]: {
+ ...commonSocialCustomCursor,
+ backgroundColor: '#ed1543',
+ opacity,
+ },
+ [CursorVariants.TAILWIND]: {
+ ...commonSocialCustomCursor,
+ backgroundColor: '#00b4b6',
+ opacity,
+ },
+ [CursorVariants.REACT]: {
+ ...commonSocialCustomCursor,
+ backgroundColor: '#61DBFB',
+ opacity,
+ },
+ [CursorVariants.FRAMER_MOTION]: {
+ ...commonSocialCustomCursor,
+ backgroundColor: 'rgb(0,0,0)',
+ opacity,
+ },
+ [CursorVariants.TYPESCRIPT]: {
+ ...commonSocialCustomCursor,
+ backgroundColor: '#007ACC',
+ opacity,
+ },
+ [CursorVariants.VUE]: {
+ ...commonSocialCustomCursor,
+ backgroundColor: '#42b883',
+ opacity,
+ },
+ };
+
+ const cursorVariantHandlers: { [K in CursorVariants]: () => void } =
+ React.useMemo(
+ () => ({
+ [CursorVariants.CONTACT]: () => {
+ setCursorText();
+ setCursorVariant(CursorVariants.CONTACT);
+ },
+ [CursorVariants.CONTACT_CTA]: () => {
+ setCursorText('Contact Us');
+ setCursorVariant(CursorVariants.CONTACT_CTA);
+ },
+ [CursorVariants.DEFAULT]: () => {
+ setCursorText('');
+ setCursorVariant(CursorVariants.DEFAULT);
+ },
+ [CursorVariants.DRIBBBLE]: () => {
+ setCursorText();
+ setCursorVariant(CursorVariants.DRIBBBLE);
+ },
+ [CursorVariants.GITHUB]: () => {
+ setCursorText();
+ setCursorVariant(CursorVariants.GITHUB);
+ },
+ [CursorVariants.HIDDEN]: () => {
+ setCursorText('');
+ setCursorVariant(CursorVariants.HIDDEN);
+ },
+ [CursorVariants.NEST_JS]: () => {
+ setCursorText();
+ setCursorVariant(CursorVariants.NEST_JS);
+ },
+ [CursorVariants.TAILWIND]: () => {
+ setCursorText();
+ setCursorVariant(CursorVariants.TAILWIND);
+ },
+ [CursorVariants.REACT]: () => {
+ setCursorText();
+ setCursorVariant(CursorVariants.REACT);
+ },
+ [CursorVariants.FRAMER_MOTION]: () => {
+ setCursorText();
+ setCursorVariant(CursorVariants.FRAMER_MOTION);
+ },
+ [CursorVariants.SHRUG]: () => {
+ setCursorText('¯\\_(ツ)_/¯');
+ setCursorVariant(CursorVariants.SHRUG);
+ },
+ [CursorVariants.TYPESCRIPT]: () => {
+ setCursorText();
+ setCursorVariant(CursorVariants.TYPESCRIPT);
+ },
+ [CursorVariants.VUE]: () => {
+ setCursorText();
+ setCursorVariant(CursorVariants.VUE);
+ },
+ [CursorVariants.IMG]: () => {
+ if (typeof cursorVariant === 'object') {
+ setCursorVariant(CursorVariants.IMG);
+ }
+ },
+ }),
+ [cursorVariant],
+ );
+
+ const cursorContextProviderValue = React.useMemo(
+ () => ({
+ cursorVariant,
+ setCursorVariant: (
+ nextCursorVariant: CursorVariants | GrayscaleImageProps,
+ ) => {
+ if (typeof nextCursorVariant === 'object') {
+ setCursorText(
+ ,
+ );
+ cursorVariantHandlers[CursorVariants.IMG]();
+ } else {
+ get(cursorVariantHandlers, [nextCursorVariant], () => null)();
+ }
+ },
+ }),
+ [cursorVariant, cursorVariantHandlers],
+ );
+
+ return (
+
+
+ {cursorText}
+
+ {children}
+
+ );
+}
diff --git a/app/hooks/useCursorVariant.ts b/app/hooks/useCursorVariant.ts
new file mode 100644
index 0000000..08442e3
--- /dev/null
+++ b/app/hooks/useCursorVariant.ts
@@ -0,0 +1,23 @@
+import type { Variant } from 'framer-motion';
+
+export enum CursorVariants {
+ CONTACT = 'CONTACT',
+ CONTACT_CTA = 'CONTACT_CTA',
+ DEFAULT = 'DEFAULT',
+ DRIBBBLE = 'DRIBBBLE',
+ FRAMER_MOTION = 'FRAMER_MOTION',
+ GITHUB = 'GITHUB',
+ HIDDEN = 'HIDDEN',
+ IMG = 'IMG',
+ NEST_JS = 'NEST_JS',
+ REACT = 'REACT',
+ SHRUG = 'SHRUG',
+ TAILWIND = 'TAILWIND',
+ TYPESCRIPT = 'TYPESCRIPT',
+ VUE = 'VUE',
+}
+
+export type UseCursorVariant = () => void;
+
+export type CustomCursor = Variant & { isRounded?: boolean };
+export const useCursorVariant: UseCursorVariant = () => {};
diff --git a/app/icons/FramerMotionIcon.tsx b/app/icons/FramerMotionIcon.tsx
new file mode 100644
index 0000000..06c6784
--- /dev/null
+++ b/app/icons/FramerMotionIcon.tsx
@@ -0,0 +1,24 @@
+import get from 'lodash-es/get';
+import React from 'react';
+
+import { BaseIcon } from '~/icons/Icon';
+import type { IconVariantProps } from '~/icons/Icon';
+
+export function FramerMotionIcon(props: IconVariantProps): React.ReactNode {
+ return (
+
+ }
+ inverted={
+
+ }
+ plain={}
+ plainInverted={}
+ variant={get(props, ['variant'])}
+ />
+ );
+}
diff --git a/app/icons/Icon.tsx b/app/icons/Icon.tsx
new file mode 100644
index 0000000..e177578
--- /dev/null
+++ b/app/icons/Icon.tsx
@@ -0,0 +1,53 @@
+import get from 'lodash-es/get';
+import { type ReactNode } from 'react';
+import type React from 'react';
+
+import type { PropsWithoutRef } from '~/lib/types';
+
+enum IconVariant {
+ REGULAR = 'REGULAR',
+ INVERTED = 'INVERTED',
+ PLAIN_REGULAR = 'PLAIN_REGULAR',
+ PLAIN_INVERTED = 'PLAIN_INVERTED',
+}
+
+interface BaseIconProps {
+ regular: ReactNode;
+ inverted: ReactNode;
+ plain: ReactNode;
+ plainInverted: ReactNode;
+ variant?: IconVariant;
+}
+
+type IconComponentProps = PropsWithoutRef<'i'>;
+
+export type IconVariantFC = React.FC<
+ IconComponentProps & { variant?: IconVariant }
+>;
+
+export interface IconVariantProps extends IconComponentProps {
+ variant?: IconVariant;
+}
+
+export const BaseIcon: React.FC = (props) => {
+ const UnknownIcon = null;
+
+ const variant: IconVariant = get(
+ props,
+ ['variant'],
+ IconVariant.PLAIN_INVERTED,
+ );
+
+ switch (variant) {
+ case IconVariant.INVERTED:
+ return get(props, ['inverted'], UnknownIcon);
+ case IconVariant.PLAIN_INVERTED:
+ return get(props, ['plainInverted'], UnknownIcon);
+ case IconVariant.PLAIN_REGULAR:
+ return get(props, ['plain'], UnknownIcon);
+ case IconVariant.REGULAR:
+ return get(props, ['regular'], UnknownIcon);
+ default:
+ return UnknownIcon;
+ }
+};
diff --git a/app/icons/NestJsIcon.tsx b/app/icons/NestJsIcon.tsx
new file mode 100644
index 0000000..5a319d7
--- /dev/null
+++ b/app/icons/NestJsIcon.tsx
@@ -0,0 +1,18 @@
+import get from 'lodash-es/get';
+import React from 'react';
+
+import { BaseIcon, type IconVariantProps } from '~/icons/Icon';
+
+export function NestJsIcon(props: IconVariantProps): React.ReactNode {
+ return (
+
+ }
+ inverted={}
+ plain={}
+ plainInverted={}
+ variant={get(props, ['variant'])}
+ />
+ );
+}
diff --git a/app/icons/ReactIcon.tsx b/app/icons/ReactIcon.tsx
new file mode 100644
index 0000000..a2cb4c6
--- /dev/null
+++ b/app/icons/ReactIcon.tsx
@@ -0,0 +1,36 @@
+import clsx from 'clsx';
+import get from 'lodash-es/get';
+import React from 'react';
+
+import { BaseIcon, type IconVariantProps } from '~/icons/Icon';
+
+export function ReactIcon(props: IconVariantProps): React.ReactNode {
+ const { className, ...rest } = props;
+
+ return (
+
+ }
+ inverted={
+
+ }
+ plain={
+
+ }
+ plainInverted={
+
+ }
+ variant={get(props, ['variant'])}
+ />
+ );
+}
diff --git a/app/icons/TailwindIcon.tsx b/app/icons/TailwindIcon.tsx
new file mode 100644
index 0000000..2641291
--- /dev/null
+++ b/app/icons/TailwindIcon.tsx
@@ -0,0 +1,18 @@
+import get from 'lodash-es/get';
+import React from 'react';
+
+import { BaseIcon, type IconVariantProps } from '~/icons/Icon';
+
+export function TailwindIcon(props: IconVariantProps): React.ReactNode {
+ return (
+
+ }
+ inverted={}
+ plain={}
+ plainInverted={}
+ variant={get(props, ['variant'])}
+ />
+ );
+}
diff --git a/app/icons/TypescriptIcon.tsx b/app/icons/TypescriptIcon.tsx
new file mode 100644
index 0000000..c3219bf
--- /dev/null
+++ b/app/icons/TypescriptIcon.tsx
@@ -0,0 +1,16 @@
+import get from 'lodash-es/get';
+import React from 'react';
+
+import { BaseIcon, type IconVariantProps } from '~/icons/Icon';
+
+export function TypescriptIcon(props: IconVariantProps): React.ReactNode {
+ return (
+ }
+ inverted={}
+ plain={}
+ plainInverted={}
+ variant={get(props, ['variant'])}
+ />
+ );
+}
diff --git a/app/icons/VueIcon.tsx b/app/icons/VueIcon.tsx
new file mode 100644
index 0000000..5719d97
--- /dev/null
+++ b/app/icons/VueIcon.tsx
@@ -0,0 +1,18 @@
+import get from 'lodash-es/get';
+import React from 'react';
+
+import { BaseIcon, type IconVariantProps } from '~/icons/Icon';
+
+export function VueIcon(props: IconVariantProps): React.ReactNode {
+ return (
+
+ }
+ inverted={}
+ plain={}
+ plainInverted={}
+ variant={get(props, ['variant'])}
+ />
+ );
+}
diff --git a/app/icons/types.ts b/app/icons/types.ts
new file mode 100644
index 0000000..e69de29
diff --git a/app/lib/GrayscaleImage.tsx b/app/lib/GrayscaleImage.tsx
new file mode 100644
index 0000000..dfa9e11
--- /dev/null
+++ b/app/lib/GrayscaleImage.tsx
@@ -0,0 +1,40 @@
+import {
+ motion,
+ useMotionTemplate,
+ useScroll,
+ useTransform,
+} from 'framer-motion';
+import React, { useRef } from 'react';
+
+export type FramerCursorAttributes = Pick<
+ Partial>,
+ 'onMouseEnter' | 'onMouseLeave'
+>;
+
+export type GrayscaleImageProps = Partial<
+ Pick
+> & {
+ alt?: string;
+} & FramerCursorAttributes;
+
+export function GrayscaleImage(props: GrayscaleImageProps): React.ReactNode {
+ const ref = useRef>(null);
+
+ const { scrollYProgress } = useScroll({
+ target: ref,
+ offset: ['start 65%', 'end 35%'],
+ });
+
+ const grayscale = useTransform(scrollYProgress, [0, 0.5, 1], [1, 0, 1]);
+ const filter = useMotionTemplate`grayscale(${grayscale})`;
+
+ return (
+
+
+
+ );
+}
diff --git a/app/root.tsx b/app/root.tsx
index 57eef1c..828da81 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -21,6 +21,7 @@ import {
import React from 'react';
import { BodyHTMLTagColorProvider } from '~/context/ColorContext';
+import { CursorContextProvider } from '~/context/CursorContext';
import '~/tailwind.css';
export const links: LinksFunction = () => [
@@ -51,9 +52,11 @@ export function Layout({
Kurocado Studio
- {children}
-
-
+
+ {children}
+
+
+