
RIFT
Februar 2025
RIFT ist ein Dateitransfer-Tool – in derselben Kategorie wie WeTransfer oder Dropbox. Es ist jedoch keine weitere saubere, vergessliche Drag-and-Drop-Box. Jede Demo unten ist die Live-Komponente, direkt aus RIFT's eigenem Storybook, keine Bildschirmaufnahme.

Was es ist
Sie öffnen einen „RIFT“ – die Beschriftung auf der Erstellungskarte lautet „RIFT KANAL / Neuen RIFT öffnen.“ Dateien ablegen, Link erhalten. Die kostenlose Stufe begrenzt einen Transfer standardmäßig auf 2 GB mit einer Gültigkeit von 3 Tagen; kostenpflichtige Stufen erhöhen beides. Jede Datei in einem Transfer wird in der Benutzeroberfläche als „Shard“ gezählt („2 Shards · 1 GB“) – das ist der einzige Gaming-Begriff, der es bis ins ausgelieferte Produkt geschafft hat.
Große Uploads werden serverseitig gegen Supabase-Speicher zerlegt und wieder zusammengesetzt, wobei Supabase auch die Authentifizierung und die Transferdatenbank verwaltet. Next.js 16 auf Turbopack betreibt die App, Base UI stellt die ungestylten Primitiven bereit, Tailwind übernimmt das Styling, Framer Motion steuert die Übergänge.
Halbton-Hintergrund und Bild-Dithering
Der dithering-ähnliche Hintergrund ist kein CSS-Gradienten-Trick – es ist ein GPU-Shader. RiftHalftoneBackdrop rendert einen HalftoneDots-Shader von @paper-design/shaders-react über einem Hintergrundbild und überwacht prefers-color-scheme, sodass er sich im Dark Mode anpasst. Dieselbe Bibliothek ermöglicht das Bild-Dithering an anderer Stelle in der Benutzeroberfläche.
Beide Verwendungen existieren aus demselben Grund: @paper-design/shaders-react initialisiert seinen Canvas standardmäßig auf fest codierte 300×150 Pixel. Jeder Wrapper fügt einen ResizeObserver hinzu, um den Canvas beim Mounten und bei Größenänderungen auf seine tatsächliche Containergröße zu korrigieren.
// 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: drei Schritte, ein PIN-ähnliches Passwort
UploadCard verwendet eine einfache dreistufige Zustandsmaschine – Upload, Konfiguration, Hochladen – anstelle einer Wizard-Bibliothek. Der Konfigurationsschritt legt die Gültigkeitsdauer fest (standardmäßig 3 Tage für kostenlose Konten), eine optionale Download-Begrenzung und ein optionales 6-stelliges numerisches Passwort, das wie eine Telefon-PIN eingegeben wird: ein Feld pro Ziffer, mit automatisch vorrückendem Fokus.
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" : stepWas der Empfänger sieht
DownloadPage schützt den Zugriff mit demselben 6-stelligen Code, wenn ein Transfer passwortgeschützt ist. Bevor der Code eingegeben wird, wird die E-Mail des Absenders durch EmailScramble geleitet – ein deterministischer Hash verschlüsselt sie in Platzhalterzeichen und entschlüsselt sie dann Zeichen für Zeichen, sobald sie enthüllt wird, anstatt die Sichtbarkeit einfach mit CSS umzuschalten.
// 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} />Warnungen mit Gefahrenstreifen
Warnungen und Fehler verwenden einen Gefahrenmodus – buchstäblich diagonale Gefahrenstreifen-Hintergründe, die unter der Statusfarbe liegen, eher wie Industrieklebeband als ein typisches Toast-Fenster.
Der Drawer-Stapel
Ein Klick auf „Verlauf“ öffnet einen Verlaufs-Drawer. Ein Klick auf einen Transfer darin öffnet einen verschachtelten Detail-Drawer darüber. Eine Dateivorschau von dort öffnet ein Medienvorschau-Modal darüber – drei gestapelte Overlays, die echte Iteration erforderten, um sie richtig zu implementieren, ohne dass eines die Klicks der anderen verschluckt.

Die Drawer begannen mit Vaul und wurden dann zu Base UI's Dialog mit modal={true} migriert – Scroll-Sperre ist kostenlos enthalten, und Ein-/Ausblendanimationen basieren auf data-[open]/data-[closed]-Attributen anstelle einer Swipe-Gesten-Bibliothek.
Sidebar z-40
TransferModal overlay z-50
TransferModal content z-[60]
TransferDetailDrawer backdrop z-[65]
TransferDetailDrawer popup z-[70]
Sonner toasts z-99998Die Verschachtelung folgt einer Regel, deren Erlernen eine Debugging-Sitzung kostete: Der Detail-Drawer ist als echtes Kind-Dialog (keepMounted, damit Exit-Animationen abgeschlossen werden können) im Verlaufs-Drawer verschachtelt, aber das Medienvorschau-Modal wird auf die oberste Ebene gehoben – ein Geschwisterelement beider Drawer, kein Kind. Ein AlertDialog oder Modal, das in einem anderen Dialog verschachtelt ist, schließt beide beim Schließen, daher wird alles, was den Lebenszyklus seines Elternteils überdauern kann, stattdessen als Geschwisterelement gerendert.
// 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 und ein Ref statt eines States
Die Homepage scrollt mit Snap-Effekt zwischen den Abschnitten bei Mauseingabe. Das Öffnen eines Drawers oder Modals muss dies unterbrechen, da sonst das Scrollen des Drawer-Inhalts auch die darunterliegende Seite scrollt. Die naive Lösung ist ein State-Wert, den der Wheel-Handler bei jedem Event prüft, was bedeutet, den Listener jedes Mal neu zu registrieren, wenn ein Modal geöffnet oder geschlossen wird.
// 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 – Authentifizierung, Datenbank, segmentierter Dateispeicher
- Base UI (Dialog/AlertDialog-Primitive) + Tailwind
- Framer Motion für Übergänge
- @paper-design/shaders-react für den Halbton-Hintergrund und Bild-Dithering
- Deutsch-erste Benutzeroberfläche, mit „Shard“/„RIFT“ als den verbleibenden Gaming-Begriffen