├── README.md
├── src
├── assets
│ └── Inter.ttf
├── main.jsx
├── App.jsx
├── Cursor.jsx
└── TextDistort.jsx
├── vite.config.js
├── .gitignore
├── index.html
├── package.json
└── eslint.config.js
/README.md:
--------------------------------------------------------------------------------
1 | # apple-glass
2 |
3 | An attempt to recreate Apple's liquid glass effect
4 |
--------------------------------------------------------------------------------
/src/assets/Inter.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tejas-swiggy/apple-glass/HEAD/src/assets/Inter.ttf
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App";
4 |
5 | ReactDOM.createRoot(document.getElementById("root")).render(
6 |
7 |
8 |
9 | );
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Liquid Glass
7 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { Canvas } from "@react-three/fiber";
2 | import { Environment } from "@react-three/drei";
3 | import { useState } from "react";
4 |
5 | // Components
6 | import Cursor from "./Cursor";
7 | import TextDistort from "./TextDistort";
8 |
9 |
10 | export default function App() {
11 | const [hoveringText, setHoveringText] = useState(false);
12 |
13 | return (
14 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@react-three/drei": "^10.1.2",
14 | "@react-three/fiber": "^9.1.2",
15 | "@react-three/postprocessing": "^3.0.4",
16 | "gsap": "^3.13.0",
17 | "postprocessing": "^6.37.4",
18 | "react": "^19.1.0",
19 | "react-dom": "^19.1.0",
20 | "three": "^0.177.0",
21 | "troika-three-text": "^0.52.4"
22 | },
23 | "devDependencies": {
24 | "@eslint/js": "^9.25.0",
25 | "@types/react": "^19.1.2",
26 | "@types/react-dom": "^19.1.2",
27 | "@vitejs/plugin-react": "^4.4.1",
28 | "eslint": "^9.25.0",
29 | "eslint-plugin-react-hooks": "^5.2.0",
30 | "eslint-plugin-react-refresh": "^0.4.19",
31 | "globals": "^16.0.0",
32 | "vite": "^6.3.5"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 |
6 | export default [
7 | { ignores: ['dist'] },
8 | {
9 | files: ['**/*.{js,jsx}'],
10 | languageOptions: {
11 | ecmaVersion: 2020,
12 | globals: globals.browser,
13 | parserOptions: {
14 | ecmaVersion: 'latest',
15 | ecmaFeatures: { jsx: true },
16 | sourceType: 'module',
17 | },
18 | },
19 | plugins: {
20 | 'react-hooks': reactHooks,
21 | 'react-refresh': reactRefresh,
22 | },
23 | rules: {
24 | ...js.configs.recommended.rules,
25 | ...reactHooks.configs.recommended.rules,
26 | 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
27 | 'react-refresh/only-export-components': [
28 | 'warn',
29 | { allowConstantExport: true },
30 | ],
31 | },
32 | },
33 | ]
34 |
--------------------------------------------------------------------------------
/src/Cursor.jsx:
--------------------------------------------------------------------------------
1 | import { useThree, useFrame } from "@react-three/fiber";
2 | import { MeshTransmissionMaterial } from "@react-three/drei";
3 | import { useRef, useState, useEffect } from "react";
4 | import { gsap } from "gsap";
5 |
6 | export default function Cursor({ isHovering }) {
7 | const meshRef = useRef();
8 | const { viewport } = useThree();
9 | const [target, setTarget] = useState({ x: 0, y: 0 });
10 |
11 | useFrame(() => {
12 | if (!meshRef.current) return;
13 |
14 | // Track cursor
15 | gsap.to(meshRef.current.position, {
16 | x: target.x,
17 | y: target.y,
18 | duration: 0.17,
19 | ease: "power2.out",
20 | });
21 | });
22 |
23 | useEffect(() => {
24 | // Convert pixel coordinates to Three coordinates
25 | const handleMouseMove = (e) => {
26 | const x = (e.clientX / window.innerWidth) * 2 - 1;
27 | const y = -(e.clientY / window.innerHeight) * 2 + 1;
28 | setTarget({
29 | x: x * viewport.width / 2,
30 | y: y * viewport.height / 2,
31 | });
32 | };
33 |
34 | window.addEventListener("mousemove", handleMouseMove);
35 | return () => window.removeEventListener("mousemove", handleMouseMove);
36 | }, [viewport]);
37 |
38 | useEffect(() => {
39 | // Scale on hover
40 | if (!meshRef.current) return;
41 | gsap.to(meshRef.current.scale, {
42 | x: isHovering ? 2.5 : 1,
43 | y: isHovering ? 2.5 : 1,
44 | z: isHovering ? 2.5 : 1,
45 | duration: 0.13,
46 | ease: "cubic-bezier(.8,-0.84,.16,1.68)",
47 | });
48 | }, [isHovering]);
49 |
50 |
51 |
52 | return (
53 |
54 |
55 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/TextDistort.jsx:
--------------------------------------------------------------------------------
1 | import { Text } from "@react-three/drei";
2 | import { useRef, useState, useEffect } from "react";
3 | import { useFrame, useThree } from "@react-three/fiber";
4 | import Inter from "./assets/Inter.ttf";
5 | import * as THREE from "three";
6 |
7 | // Damping for magnetic interaction
8 | const damping = 0.1;
9 |
10 | export default function TextDistort({ setHoveringText }) {
11 | const textRef = useRef();
12 | const { viewport, size } = useThree();
13 | const [isHovered, setIsHovered] = useState(false);
14 | const currentOffset = useRef(new THREE.Vector3(0, 0, 0));
15 | const mouse = useRef({ x: 0, y: 0 });
16 |
17 | // Convert pixel coordinates to Three
18 | useEffect(() => {
19 | const handleMouseMove = (e) => {
20 | const x = (e.clientX / size.width) * 2 - 1;
21 | const y = -(e.clientY / size.height) * 2 + 1;
22 | mouse.current.x = x * (viewport.width / 2);
23 | mouse.current.y = y * (viewport.height / 2);
24 | };
25 |
26 | window.addEventListener("mousemove", handleMouseMove);
27 | return () => window.removeEventListener("mousemove", handleMouseMove);
28 | }, [size, viewport]);
29 |
30 | useFrame(() => {
31 | if (!textRef.current) return;
32 |
33 | const mesh = textRef.current;
34 | const offsetX = mouse.current.x * damping;
35 | const offsetY = mouse.current.y * damping;
36 |
37 | if (isHovered) {
38 | // Smoothly approach the offset from original position
39 | currentOffset.current.x += (offsetX - currentOffset.current.x) * 0.1;
40 | currentOffset.current.y += (offsetY - currentOffset.current.y) * 0.1;
41 | } else {
42 | // Smoothly return to original position
43 | currentOffset.current.x += (0 - currentOffset.current.x) * 0.1;
44 | currentOffset.current.y += (0 - currentOffset.current.y) * 0.1;
45 | }
46 |
47 | mesh.position.x = currentOffset.current.x;
48 | mesh.position.y = currentOffset.current.y;
49 | });
50 |
51 | return (
52 | {
67 | e.stopPropagation();
68 | setIsHovered(true);
69 | setHoveringText(true);
70 | }}
71 | onPointerOut={(e) => {
72 | e.stopPropagation();
73 | setIsHovered(false);
74 | setHoveringText(false);
75 | }}
76 | >
77 | Liquid Glass
78 |
79 | );
80 | }
81 |
--------------------------------------------------------------------------------