Solace sign-in

UserBigcatgj
原作者: motionsites
原文链接
2026/8/17
通用
Sign In Form

提示词内容

Build a fullscreen sign-in page with a **real-time liquid glass refraction effect** over a looping background video. Use React 18 + TypeScript + Tailwind CSS + Vite. Only dependency beyond React is `lucide-react`. Font: **Inter** (400, 500, 600, 700) from Google Fonts. Must be fully mobile responsive.

Create exactly 4 files: `src/LiquidGlass.tsx`, `src/FadeUp.tsx`, `src/App.tsx`, `src/index.css`.

---

## CRITICAL: The glass effect is NOT a CSS blur or backdrop-filter. It is a per-pixel canvas refraction engine. You MUST use the EXACT code below for `src/LiquidGlass.tsx`. Do not modify, simplify, or rewrite any of the math. Copy it verbatim:

```tsx
import { useEffect, useRef } from 'react';

/* eslint-disable @typescript-eslint/no-explicit-any */

class LiquidGlass {
  bg: any;
  opt: any;
  _lut: Float32Array | null;
  _lutKey: string;

  constructor(bgEl: any, options: any = {}) {
    this.bg = bgEl;
    this.opt = Object.assign({
      size: 120, shape: 'circle', rx: null,
      distort: 0.06, edgeCurl: 0.04, brightness: 0.06,
      specular: 0.20, border: 0.18, borderWidth: 1,
      sceneW: null, sceneH: null,
    }, options);
    this._lut = null;
    this._lutKey = '';
  }

  _getLUT(D: number) {
    const key = `${D}:${this.opt.distort}:${this.opt.edgeCurl}`;
    if (this._lut && this._lutKey === key) return this._lut;
    this._lutKey = key;
    const lut = new Float32Array(256);
    for (let i = 0; i < 256; i++) {
      const r = i / 255;
      if (r < 0.7) {
        lut[i] = r * (1.0 - this.opt.distort * (1 - r));
      } else {
        const t = (r - 0.7) / 0.3;
        lut[i] = Math.min(0.985, r * (1 - this.opt.distort * (1 - r)) + t * t * this.opt.edgeCurl);
      }
    }
    return (this._lut = lut);
  }

  _inShape(nx: number, ny: number, D: number) {
    const { shape, rx, size } = this.opt;
    if (shape === 'circle') return nx * nx + ny * ny < 1.0;
    const r = (rx != null ? rx : size * 0.3) / (size / 2);
    const ax = Math.abs(nx), ay = Math.abs(ny), lim = 1 - r;
    if (ax > 1 || ay > 1) return false;
    if (ax <= lim || ay <= lim) return true;
    return (ax - lim) ** 2 + (ay - lim) ** 2 <= r * r;
  }

  _normR(nx: number, ny: number, D: number) {
    const { shape, rx, size } = this.opt;
    if (shape === 'circle') return Math.sqrt(nx * nx + ny * ny);
    const r = (rx != null ? rx : size * 0.3) / (size / 2);
    const ax = Math.abs(nx), ay = Math.abs(ny), lim = 1 - r;
    const dx = Math.max(0, ax - lim), dy = Math.max(0, ay - lim);
    const dr = Math.sqrt(dx * dx + dy * dy);
    return (dr > 0 ? (lim + dr) : Math.max(ax, ay));
  }

  _cropBackground(cx: number, cy: number, D: number, sW: number, sH: number) {
    if (!this.bg) return null;
    const bw = this.bg.naturalWidth || this.bg.videoWidth;
    const bh = this.bg.naturalHeight || this.bg.videoHeight;
    if (!bw || !bh) return null;
    const R = D / 2;
    const off = document.createElement('canvas');
    off.width = D; off.height = D;
    const octx = off.getContext('2d')!;
    octx.drawImage(this.bg,
      Math.max(0, (cx - R) * (bw / sW)), Math.max(0, (cy - R) * (bh / sH)),
      D * (bw / sW), D * (bh / sH),
      0, 0, D, D
    );
    return octx.getImageData(0, 0, D, D).data;
  }

  _bilinear(sd: Uint8ClampedArray, x: number, y: number, D: number): [number, number, number] {
    const x0 = Math.floor(x), y0 = Math.floor(y);
    const x1 = Math.min(D - 1, x0 + 1), y1 = Math.min(D - 1, y0 + 1);
    const fx = x - x0, fy = y - y0;
    const w00 = (1 - fx) * (1 - fy), w10 = fx * (1 - fy), w01 = (1 - fx) * fy, w11 = fx * fy;
    const a = (y0 * D + x0) * 4, b = (y0 * D + x1) * 4, c = (y1 * D + x0) * 4, d = (y1 * D + x1) * 4;
    return [
      sd[a] * w00 + sd[b] * w10 + sd[c] * w01 + sd[d] * w11,
      sd[a + 1] * w00 + sd[b + 1] * w10 + sd[c + 1] * w01 + sd[d + 1] * w11,
      sd[a + 2] * w00 + sd[b + 2] * w10 + sd[c + 2] * w01 + sd[d + 2] * w11,
    ];
  }

  _drawOverlays(ctx: CanvasRenderingContext2D, D: number, R: number) {
    const { shape, rx, size, specular, border, borderWidth } = this.opt;
    const RX = rx != null ? rx : size * 0.3;
    ctx.save();
    ctx.beginPath();
    shape === 'circle' ? ctx.arc(R, R, R, 0, Math.PI * 2) : ctx.roundRect(0, 0, D, D, RX);
    ctx.clip();
    if (specular > 0) {
      const grd = ctx.createRadialGradient(R * 0.5, R * 0.28, 0, R * 0.5, R * 0.38, R * 0.45);
      grd.addColorStop(0, `rgba(255,255,255,${specular})`);
      grd.addColorStop(0.6, `rgba(255,255,255,${+(specular * 0.2).toFixed(3)})`);
      grd.addColorStop(1, 'rgba(255,255,255,0)');
      ctx.fillStyle = grd; ctx.fillRect(0, 0, D, D);
    }
    ctx.restore();
    if (border > 0) {
      const h = borderWidth / 2;
      ctx.save();
      ctx.strokeStyle = `rgba(255,255,255,${border})`;
      ctx.lineWidth = borderWidth;
      ctx.beginPath();
      shape === 'circle' ? ctx.arc(R, R, R - h, 0, Math.PI * 2)
        : ctx.roundRect(h, h, D - borderWidth, D - borderWidth, RX);
      ctx.stroke(); ctx.restore();
    }
  }

  render(canvas: HTMLCanvasElement, cx: number, cy: number, sW: number, sH: number) {
    const D = this.opt.size, R = D / 2;
    if (canvas.width !== D || canvas.height !== D) {
      canvas.width = D; canvas.height = D;
    }
    const sd = this._cropBackground(cx, cy, D, sW, sH);
    if (!sd) return false;
    const lut = this._getLUT(D);
    const ctx = canvas.getContext('2d')!;
    const out = ctx.createImageData(D, D);
    const od = out.data;
    const boost = this.opt.brightness;
    for (let py = 0; py < D; py++) {
      for (let px = 0; px < D; px++) {
        const nx = (px / R) - 1, ny = (py / R) - 1;
        if (!this._inShape(nx, ny, D)) {
          const i = (py * D + px) * 4; od[i] = od[i + 1] = od[i + 2] = od[i + 3] = 0; continue;
        }
        const normR = this._normR(nx, ny, D);
        const alpha = normR > 0.93 ? Math.max(0, (1 - normR) / 0.07) : 1.0;
        const theta = Math.atan2(ny, nx);
        const bend = lut[Math.min(255, Math.round(normR * 255))];
        const sx = Math.min(D - 1.001, Math.max(0, bend * Math.cos(theta) * R + R));
        const sy = Math.min(D - 1.001, Math.max(0, bend * Math.sin(theta) * R + R));
        const [rv, gv, bv] = this._bilinear(sd, sx, sy, D);
        const b = (1 + boost * Math.max(0, 1 - normR * 1.6)) * 0.75;
        const oi = (py * D + px) * 4;
        od[oi] = Math.min(255, rv * b); od[oi + 1] = Math.min(255, gv * b);
        od[oi + 2] = Math.min(255, bv * b); od[oi + 3] = Math.round(alpha * 255);
      }
    }
    ctx.clearRect(0, 0, D, D);
    ctx.putImageData(out, 0, 0);
    this._drawOverlays(ctx, D, R);
    return true;
  }
}

const GLASS_OPTIONS = {
  shape: 'roundedrect' as const,
  rx: 16,
  distort: 0.06,
  edgeCurl: 0.04,
  brightness: 0.06,
  specular: 0.20,
  border: 0.18,
  borderWidth: 1,
};

interface LiquidGlassCanvasProps {
  videoRef: React.RefObject<HTMLVideoElement | null>;
  sceneRef: React.RefObject<HTMLDivElement | null>;
  cardRef: React.RefObject<HTMLDivElement | null>;
}

export default function LiquidGlassCanvas({ videoRef, sceneRef, cardRef }: LiquidGlassCanvasProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    let glass: LiquidGlass | null = null;
    let rafId: number | null = null;
    let isRunning = false;

    const BG_VIDEO = videoRef.current;
    const SCENE_EL = sceneRef.current;
    const CARD_EL = cardRef.current;
    const GLASS_CV = canvasRef.current;

    if (!BG_VIDEO || !SCENE_EL || !CARD_EL || !GLASS_CV) return;

    function setupGlass() {
      const sceneR = SCENE_EL!.getBoundingClientRect();
      const cardR = CARD_EL!.getBoundingClientRect();
      const W = Math.round(cardR.width);
      const H = Math.round(cardR.height);
      const isMobile = W < 640;
      const D = isMobile ? W : Math.max(W, H);

      GLASS_CV!.width = D;
      GLASS_CV!.height = D;

      glass = new LiquidGlass(BG_VIDEO, Object.assign({}, GLASS_OPTIONS, {
        size: D,
        sceneW: sceneR.width,
        sceneH: sceneR.height,
      }));
    }

    function renderLoop() {
      if (!glass) return;
      const sceneR = SCENE_EL!.getBoundingClientRect();
      const cardR = CARD_EL!.getBoundingClientRect();
      const cx = cardR.left - sceneR.left + cardR.width / 2;
      const cy = cardR.top - sceneR.top + cardR.height / 2;
      glass.opt.sceneW = sceneR.width;
      glass.opt.sceneH = sceneR.height;
      glass.render(GLASS_CV!, cx, cy, sceneR.width, sceneR.height);
      rafId = requestAnimationFrame(renderLoop);
    }

    function startGlass() {
      if (isRunning) return;
      isRunning = true;
      setupGlass();
      renderLoop();
    }

    function stopGlass() {
      if (rafId) cancelAnimationFrame(rafId);
      rafId = null;
      isRunning = false;
    }

    function handleResize() {
      stopGlass();
      setupGlass();
      renderLoop();
      isRunning = true;
    }

    BG_VIDEO.addEventListener('canplay', startGlass);
    BG_VIDEO.addEventListener('playing', startGlass);

    if (BG_VIDEO.readyState >= 3) startGlass();

    window.addEventListener('resize', handleResize);

    return () => {
      stopGlass();
      BG_VIDEO.removeEventListener('canplay', startGlass);
      BG_VIDEO.removeEventListener('playing', startGlass);
      window.removeEventListener('resize', handleResize);
    };
  }, [videoRef, sceneRef, cardRef]);

  return (
    <canvas
      ref={canvasRef}
      style={{
        position: 'absolute',
        top: 0,
        left: 0,
        width: '100%',
        height: '100%',
        display: 'block',
        zIndex: 0,
      }}
    />
  );
}
```

---

## `src/FadeUp.tsx` -- Use this exact code:

```tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';

interface FadeUpProps {
  children: ReactNode;
  delay?: number;
  duration?: number;
  className?: string;
}

export default function FadeUp({
  children,
  delay = 0,
  duration = 700,
  className = '',
}: FadeUpProps) {
  const [visible, setVisible] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const timer = setTimeout(() => setVisible(true), delay);
    return () => clearTimeout(timer);
  }, [delay]);

  return (
    <div
      ref={ref}
      className={className}
      style={{
        opacity: visible ? 1 : 0,
        transform: visible ? 'translateY(0)' : 'translateY(20px)',
        transition: `opacity ${duration}ms cubic-bezier(0.16, 1, 0.3, 1), transform ${duration}ms cubic-bezier(0.16, 1, 0.3, 1)`,
        transitionDelay: '0ms',
      }}
    >
      {children}
    </div>
  );
}
```

---

## `src/index.css`

```css
@tailwind base;
@tailwind components;
@tailwind utilities;

* { margin: 0; padding: 0; box-sizing: border-box; }
body {
  font-family: 'Inter', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
```

---

## `index.html` head -- Load Inter font:

```html
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Solace - Login</title>
```

---

## `src/App.tsx` -- The sign-in page layout

### Structure:

1. **Outermost div** (assign `sceneRef`): `relative min-h-screen w-full overflow-hidden`. Contains the video and the centered card.

2. **Background video** (assign `videoRef`): `absolute inset-0 h-full w-full object-cover`, autoPlay, muted, loop, playsInline, crossOrigin="anonymous".
   **Video URL:** `https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260606_135315_5f9e8a4c-09bc-4a97-9f75-8a387d4258ee.mp4`

3. **Centering wrapper:** `relative z-10 flex min-h-screen items-center justify-center px-4 py-8`.

4. **Card** (assign `cardRef`): `relative w-full max-w-lg overflow-hidden rounded-2xl p-6 shadow-2xl sm:p-10 md:p-14`. No background color -- the `<LiquidGlassCanvas>` component is placed inside as the first child, acting as the card's background. All form content goes inside a `relative z-[1]` wrapper div above the canvas.

### Card content (each item wrapped in `<FadeUp>` with staggered delays 0, 100, 200, ... 900ms):

1. **(delay 0) Logo:** An inline SVG, 48x48, viewBox `0 0 256 256`, white fill. Path: `M 128 192 L 128 256 L 64.5 256 L 32 223 L 0 192 L 0 128 L 64 128 Z M 256 192 L 256 256 L 192.5 256 L 160 223 L 128 192 L 128 128 L 192 128 Z M 128 64 L 128 128 L 64.5 128 L 32 95 L 0 64 L 0 0 L 64 0 Z M 256 64 L 256 128 L 192.5 128 L 160 95 L 128 64 L 128 0 L 192 0 Z`. Centered, `mb-6`.

2. **(delay 100) Heading:** `"Step back in!"` -- `text-3xl sm:text-4xl md:text-5xl font-medium tracking-tight text-center mb-2`. Text styled with `bg-gradient-to-r from-white to-purple-300 bg-clip-text text-transparent`.

3. **(delay 200) Subtitle:** `"Log in to continue your mindful exercises, calm routines, and wellness pathway"` -- `text-xs sm:text-sm md:text-base text-white/60 leading-relaxed text-center mb-6 sm:mb-8`. A `<br>` hidden on mobile (`hidden sm:inline`).

4. **(delay 300) Email field:** Label `"Email"` (`text-sm font-medium text-white/70 mb-1.5 block`). Input: `w-full rounded-xl border border-white/15 bg-white/5 px-4 py-3.5 text-sm text-white placeholder-white/30 outline-none transition-colors focus:border-white/30 focus:bg-white/10`. Placeholder: `"Your email address"`.

5. **(delay 400) Password field:** Same label/input styling. Password toggle button using `Eye`/`EyeOff` from lucide-react (size 20): `absolute right-4 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/70`. Placeholder: `"Type your password"`.

6. **(delay 500) Remember/Reset row:** `flex items-center justify-between`. Left: custom checkbox using hidden input with Tailwind `peer` -- a 20x20 div (`h-5 w-5 rounded border border-white/20 bg-white/5 peer-checked:border-purple-400 peer-checked:bg-purple-500`) plus a white checkmark SVG (`absolute left-0.5 top-0.5 hidden peer-checked:block h-4 w-4`, path `M5 13l4 4L19 7` strokeWidth 3). Label `"Stay signed in"` (`text-sm text-white/70`). Right: `"Reset password?"` button (`text-sm text-white/70 hover:text-white`).

7. **(delay 600) Sign In button:** `w-full rounded-full bg-white py-3.5 text-base font-semibold text-gray-900 hover:bg-white/90 active:scale-[0.98]`.

8. **(delay 700) Divider:** `my-6 flex items-center gap-4` with two `h-px flex-1 bg-white/15` lines around `"Or"` (`text-sm text-white/40`).

9. **(delay 800) Google button:** `flex w-full items-center justify-center gap-3 rounded-full border border-white/15 bg-white/5 py-3.5 text-sm font-medium text-white hover:bg-white/10 active:scale-[0.98]`. Contains the standard 4-color Google "G" SVG (20x20, paths with fills `#4285F4`, `#34A853`, `#FBBC05`, `#EA4335`) and text `"Continue with Google"`.

10. **(delay 900) Join link:** `mt-6 text-center text-sm text-white/50` with `"New to this platform?"` and a `"Join Now"` button (`font-medium text-white hover:text-purple-300`).

---

The entire form is wrapped in `<form className="space-y-5">`. The form `onSubmit` handler calls `e.preventDefault()`.

---

暂无效果展示

作者尚未提供生成效果案例;新的公开提示词需要至少添加一个案例。

想要不同效果?只改几处即可

AI 自动找出可替换的部分(主体/风格/场景),填入想要的值,其余保持不变

提示词广场登录