Rift Thumbnail Bild

RIFT

Februar 2025

RIFT is a file transfer tool—in the same category as WeTransfer or Dropbox. However, it's not another clean, forgettable drag-and-drop box. Each demo below is the live component, straight from RIFT's own Storybook, not a screen recording.

RIFT landing page with the halftone shader backdrop and the RIFT-Kanal upload card

What It Is

You open a 'RIFT'—the label on the creation card reads 'RIFT CHANNEL / Open New RIFT.' Drop files, get a link. The free tier defaults a transfer to 2 GB with a 3-day validity; paid tiers increase both. Each file in a transfer is counted as a 'Shard' in the UI ('2 Shards · 1 GB')—this is the only gaming term that made it into the shipped product.

Large uploads are chunked and reassembled server-side against Supabase Storage, with Supabase also managing authentication and the transfer database.Next.js 16 on Turbopack powers the app, Base UI provides the unstyled primitives,Tailwind handles the styling, Framer Motion controls transitions.

Halftone Backdrop and Image Dithering

The dithering-like background isn't a CSS gradient trick—it's a GPU shader. RiftHalftoneBackdrop renders a HalftoneDots-shader from @paper-design/shaders-react over a background image and observes prefers-color-scheme, so it adapts in dark mode. The same library enables image dithering elsewhere in the UI.

rift
Halftone shader backdrop
GPU shader (HalftoneDots from @paper-design/shaders-react) over a background image, dark-mode aware.
rift
Image dithering shader
Same shader library, applied to images instead of the backdrop — black/white dither variant.

Both uses exist for the same reason: @paper-design/shaders-react initializes its canvas to a hardcoded 300×150 pixels by default. Each wrapper adds a ResizeObserver to correct the canvas to its actual container size on mount and resize.

typescript
// Recurring fix, used by both RiftHalftoneBackdrop and ImageDitheringWrapper
useEffect(() => {
  const container = containerRef.current
  if (!container) return

  const resizeObserver = new ResizeObserver((entries) => {
    const canvas = container.querySelector('canvas')
    if (!canvas) return
    const { width, height } = entries[0].contentRect
    canvas.width = width
    canvas.height = height
  })
  resizeObserver.observe(container)
  return () => resizeObserver.disconnect()
}, [])

Upload: Three Steps, a PIN-like Password

UploadCard uses a simple three-step state machine—Upload, Configure, Uploading—instead of a wizard library. The configuration step sets the validity period (defaulting to 3 days for free accounts), an optional download limit, and an optional 6-digit numeric password entered like a phone PIN: one field per digit, with auto-advancing focus.

rift
Upload — configuration step
Set expiration (3-day free default), an optional download cap, and the 6-digit PIN-style password.
rift
Upload — in progress
Chunked upload in flight, reassembled server-side against Supabase storage.
typescript
type Step = "upload" | "config" | "uploading"

const [step, setStep] = useState<Step>("upload")
const [expirationDays, setExpirationDays] = useState(3) // free-tier default
const [passwordChars, setPasswordChars] = useState<string[]>(Array(6).fill(""))
const [enablePassword, setEnablePassword] = useState(false)

const activeStep: Step = isUploading ? "uploading" : step

What the Recipient Sees

DownloadPage protects access with the same 6-digit code if a transfer is password-protected. Before the code is entered, the sender's email is run through EmailScramble—a deterministic hash encrypts it into placeholder characters, then decrypts it character by character once revealed, rather than simply toggling visibility with CSS.

rift
EmailScramble reveal
Sender's email stays scrambled until the recipient passes the password check, then decodes character by character.
typescript
// Deterministic hash → placeholder chars, so the scramble is
// stable per email instead of re-randomizing on every render
function makePlaceholder(seed: string, length: number): string {
  let h = 0
  for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
  let out = ""
  for (let i = 0; i < length; i++) {
    h = Math.imul(1103515245, h) + 12345
    out += CHARS[Math.abs(h >>> 0) % CHARS.length]
  }
  return out
}

// revealed flips true only after password verification succeeds
<EmailScramble text={senderEmail} revealed={!needsPassword || isVerified} />
rift
Download page — password gate
The 6-digit code entry the recipient sees for a password-protected transfer.
rift
Download page — file previews
Previewable files render inline before download; PDFs are pre-fetched once verified so preview opens instantly.
rift
Download page — expired state
What a recipient sees once a Rift's expiration window has passed.

Warnings with Hazard Stripes

Warnings and errors use a hazard mode—literally diagonal hazard stripe backgrounds that sit beneath the status color, more like industrial tape than a typical toast window.

rift
Hazard alert states
Diagonal hazard-stripe backgrounds (hazard-bg-red/yellow/green/blue/purple) layered under each status color.

The Drawer Stack

Clicking 'History' opens a history drawer. Clicking a transfer within it opens a nested detail drawer on top. A file preview from there opens a media preview modal on top of that—three stacked overlays that required real iteration to implement correctly without one swallowing the others' clicks.

The Verlauf drawer listing past transfers, each counted in Shards

The drawers started with Vaul, then migrated to Base UI's Dialog with modal={true}—scroll locking is included for free, and enter/exit animations are based on data-[open]/data-[closed] attributes instead of a swipe gesture library.

bash
Sidebar                          z-40
TransferModal overlay            z-50
TransferModal content            z-[60]
TransferDetailDrawer backdrop    z-[65]
TransferDetailDrawer popup       z-[70]
Sonner toasts                    z-99998

The nesting follows a rule that took a debugging session to learn: the detail drawer is nested as a true child dialog (keepMounted so exit animations can complete) within the history drawer, but the media preview modal is lifted to the top level—a sibling of both drawers, not a child. An AlertDialog or Modal nested within another dialog closes both on dismissal, so anything that can outlive its parent's lifecycle is rendered as a sibling instead.

typescript
// TransferModal owns preview state, both drawers just report into it
const [preview, setPreview] = useState<{ files: Shard[]; index: number } | null>(null)

<Dialog.Root open={verlaufOpen}>
  {/* ...transfer list... */}
  <TransferDetailDrawer
    onOpenPreview={(files, index) => setPreview({ files, index })}
  />
</Dialog.Root>

{/* sibling, not nested — survives the drawer above it closing */}
<MediaPreviewModal state={preview} onClose={() => setPreview(null)} />

Snap Scroll and a Ref Instead of State

The homepage snap-scrolls between sections on mouse input. Opening a drawer or modal must interrupt this, or scrolling the drawer's content will also scroll the page beneath. The naive solution is a state value that the wheel handler checks on every event, which means re-registering the listener every time a modal opens or closes.

typescript
// MainLayout — a ref instead of state, so the listener registers once
const modalOpenRef = useRef(false)

useEffect(() => {
  function handleWheel(e: WheelEvent) {
    if (modalOpenRef.current) return // suspended, no re-register needed
    // ...snap-scroll logic
  }
  window.addEventListener("wheel", handleWheel, { passive: false })
  return () => window.removeEventListener("wheel", handleWheel)
}, []) // empty deps — the ref makes that safe

// opening any modal just flips the ref, no re-render required
const openModal = () => { modalOpenRef.current = true; setModalOpen(true) }

Stack

  • Next.js 16 (Turbopack)
  • Supabase – Authentication, Database, segmented file storage
  • Base UI (Dialog/AlertDialog primitives) + Tailwind
  • Framer Motion for transitions
  • @paper-design/shaders-react for halftone backdrop and image dithering
  • German-first UI, with 'Shard'/'RIFT' as the remaining gaming terms