U-HAUL TRUCK RESERVATION · LIVE PROJECT

Redesigning self-serve truck pickup and return

© U-Haul Truck Reservation

ROLE

Lead Designer

TIMELINE

Ongoing

TEAM

12-person cross-functional team

SKILLS

Service Design
UX Research
Interaction Design
Prototyping

PROBLEM

Self-serve pickup left customers guessing

Customers reserving a truck online often arrived at pickup unsure where to go, how to unlock the vehicle, or what verification was required. Confusion around key drop-off locations and unclear next steps drove support calls and made a self-service experience feel anything but self-service.

Pickup Flow Mapping

Mapped the end-to-end pickup and return journey to identify where customers got stuck or gave up.

On-Location Observation

Watched customers attempt pickup at real locations to see firsthand where confusion and delays occurred.

Cross-functional Collaboration

Partnered with ops and store teams to validate that proposed changes were feasible within existing rental hardware and staffing constraints.

SOLUTION

A guided, in-app pickup and return flow

We introduced step-by-step in-app guidance covering truck location, identity verification, and return instructions — surfacing the right information at the right moment so customers could complete pickup and return without needing staff assistance.

CORE FLOWS

A guided path through pickup and return

Each pickup and return moment was mapped as its own micro-flow — vehicle location, key access, condition check, and confirmation — so customers always knew the next step without waiting on staff.

Mobile Reservation

Let customers reserve and pay for their truck ahead of time, so pickup becomes a formality instead of a transaction.

Remote Verification

Move license and identity verification into the app, removing the front-desk bottleneck during peak hours.

Self-Serve Pickup

Give customers a way to unlock and inspect the truck on their own, so staff are only needed for exceptions.

RESEARCH

Early signals pointed to a bigger problem

Support ticket themes and early customer interviews consistently pointed to the same friction points: unclear pickup locations, uncertainty about who was responsible for vehicle condition, and confusion about what identification was needed on-site. These signals shaped the direction before a single screen was designed.

Core flows

Verify identity and arrive ready

Customers upload a photo of their license before pickup, and once verified, the app confirms their truck is reserved and ready — no front-desk check-in required.

Start the pickup process from the app

A dedicated screen walks customers through what to expect next, with a single "Begin pickup process" button instead of waiting in line for staff.

Browse and select the right truck

Customers see live availability and pricing for every truck size at their location, so they can pick the right fit and confirm before heading out to the lot.

research

Testing Real Pickup Flows, Not Just Mockups

Because a self-serve pickup can't be evaluated as a static screen, I tested four distinct entry patterns with real customers at a pickup location to see which one actually reduced hesitation and got people to their truck fastest.

Competitor Analysis

Reservation & Dispatch Flows in Truck Rental and Moving Apps

Reviewed five reservation and moving apps to see how they handle self-service pickup: most succeed with short, transparent steps and few or no upsells, but only Fluid offers self-dispatch and self-return like U-Haul — and even it front-loads verification into the reservation step rather than the dispatch step.

Strategic directions

Three Bets on How Self-Service Should Work

The directions that shaped the final pickup experience.

Tradeoff: This surfaced during an internal team session (pictured below), where we mapped the customer journey and curated opportunities and open research questions from it. One idea we explored was a Bird/Lime-style approach to help customers locate their truck in a lot — but retrofitting sensors across U-Haul's existing fleet wasn't cost-effective at scale. We chose a lighter-weight solution instead: an in-app navigation map paired with clear parking lot labeling, so customers could find their truck without new hardware.

Confidence Before Commitment

Show truck size, pickup steps, and ID requirements before the reservation is confirmed, so nothing about pickup day comes as a surprise.

Verification, Not Interrogation

Move license and ID verification into the app before pickup, so the in-person moment feels like confirmation, not a checkpoint.

Fail Gracefully

Design clear fallback paths, like calling support or flagging an attendant, for the edge cases self-service can't solve on its own.

Final designs

We prioritized a simple, familiar, and clean interface.

Because a self-serve pickup can't be evaluated as a static screen, I tested four distinct entry patterns with real customers at a pickup location to see which one actually reduced hesitation and got people to their truck fastest.

reflections

What I learned

Testing in the Real World

Testing with real customers at an actual pickup location surfaced friction a clickable prototype would never have caught.

Design and Build

Rapid prototyping let me test multiple entry patterns with real customers before committing engineering resources — collapsing the gap between design and validation.

Clarity Over Convenience

Showing customers what to expect before pickup outperformed adding more automation — a reminder that trust is built through clarity, not just speed.

U-Haul Truck Reservation

U-Haul Homepage

import { forwardRef, useEffect, useRef, useState } from "react"

import type { ComponentType } from "react"


export function ScrollSpyToc(Component): ComponentType {

return forwardRef((props, ref) => {

const [activeHref, setActiveHref] = useState<string | null>(null)


useEffect(() => {

const THRESHOLD = 160

const OFFSET = 140

let lastActive: string | null = null

let targets: {

id: string

el: HTMLElement

link: HTMLAnchorElement

}[] = []

let didInitialHashScroll = false


const getHashId = (href: string | null) => {

if (!href) return null

const i = href.lastIndexOf("#")

if (i === -1) return null

const id = href.slice(i + 1)

return id || null

}


const scrollToTarget = (

el: HTMLElement,

behavior: ScrollBehavior

) => {

const top =

el.getBoundingClientRect().top + window.scrollY - OFFSET

window.scrollTo({ top: Math.max(top, 0), behavior })

}


const setupLinks = () => {

const allLinks = Array.from(

document.querySelectorAll("a")

) as HTMLAnchorElement[]

const linksWithHash = allLinks.filter((link) =>

getHashId(link.getAttribute("href"))

)

if (linksWithHash.length === 0) return false


const newTargets = linksWithHash

.map((link) => {

const id = getHashId(link.getAttribute("href"))

const el = id ? document.getElementById(id) : null

return el

? { id: id as string, el: el, link: link }

: null

})

.filter(function (t) {

return Boolean(t)

}) as {

id: string

el: HTMLElement

link: HTMLAnchorElement

}[]


if (newTargets.length === 0) return false

targets = newTargets


linksWithHash.forEach((link) => {

link.style.transition =

"color 0.15s ease, font-weight 0.15s ease"

})


if (!didInitialHashScroll) {

didInitialHashScroll = true

const initialId = getHashId(window.location.hash)

const initialTarget = initialId

? targets.find((t) => t.id === initialId)

: null

if (initialTarget) {

lastActive = initialTarget.id

setActiveHref(initialTarget.id)

window.requestAnimationFrame(() => {

scrollToTarget(initialTarget.el, "auto")

})

}

}


return true

}


// Registered on window with capture:true so this runs BEFORE

// Framer's own internal link/router click handling (which

// intercepts same-page hash links, updates the URL via History

// API, but does not itself scroll -- and stops the event from

// ever reaching a normal bubble-phase listener). Capturing on

// window guarantees we see the click first, so we can call

// stopImmediatePropagation() and own the navigation + scroll

// ourselves with the correct offset.

const clickHandler = (e: MouseEvent) => {

const targetEl = e.target as HTMLElement

const link = targetEl.closest("a") as HTMLAnchorElement | null

if (!link) return

const id = getHashId(link.getAttribute("href"))

if (!id) return

const el = document.getElementById(id)

if (!el) return

if (!targets.some((t) => t.id === id)) return

e.preventDefault()

e.stopImmediatePropagation()

lastActive = id

setActiveHref(id)

scrollToTarget(el, "smooth")

}

window.addEventListener("click", clickHandler, true)


const applyStyles = (activeId: string | null) => {

const allLinks = Array.from(

document.querySelectorAll("a")

) as HTMLAnchorElement[]

allLinks.forEach((link) => {

const id = getHashId(link.getAttribute("href"))

if (!id) return

if (!targets.some((t) => t.id === id)) return

const isActive = id === activeId

link.style.color = isActive ? "#111111" : "#9a9a9a"

link.style.fontWeight = isActive ? "500" : "400"

})

}


const updateActive = () => {

if (targets.length === 0) {

setupLinks()

if (targets.length === 0) return

}

let bestId: string | null = null

let bestTop = -Infinity

for (let i = 0; i < targets.length; i++) {

const t = targets[i]

if (!t.el.isConnected) continue

const top = t.el.getBoundingClientRect().top

if (top <= THRESHOLD && top > bestTop) {

bestTop = top

bestId = t.id

}

}

if (!bestId) bestId = targets[0].id

if (bestId !== lastActive) {

lastActive = bestId

setActiveHref(bestId)

}

applyStyles(bestId)

}

const intervalId = window.setInterval(updateActive, 150)

updateActive()


return () => {

window.clearInterval(intervalId)

window.removeEventListener("click", clickHandler, true)

}

}, [])


return <Component ref={ref} {...props} />

})

}

import { forwardRef, useEffect, useRef, useState } from "react"

import type { ComponentType } from "react"


export function ScrollSpyToc(Component): ComponentType {

return forwardRef((props, ref) => {

const [activeHref, setActiveHref] = useState<string | null>(null)


useEffect(() => {

;(window as any).__sstDebug = []

const log = (msg: string, extra?: any) => {

;(window as any).__sstDebug.push({ msg, extra, t: Date.now() })

}

log("effect mounted")

const THRESHOLD = 160

const OFFSET = 140

let lastActive: string | null = null

let targets: {

id: string

el: HTMLElement

link: HTMLAnchorElement

}[] = []

let didInitialHashScroll = false


const getHashId = (href: string | null) => {

if (!href) return null

const i = href.lastIndexOf("#")

if (i === -1) return null

const id = href.slice(i + 1)

return id || null

}


const scrollToTarget = (

el: HTMLElement,

behavior: ScrollBehavior

) => {

const top =

el.getBoundingClientRect().top + window.scrollY - OFFSET

log("scrollToTarget", { top, max: Math.max(top, 0) })

window.scrollTo({ top: Math.max(top, 0), behavior })

}


const setupLinks = () => {

const allLinks = Array.from(

document.querySelectorAll("a")

) as HTMLAnchorElement[]

const linksWithHash = allLinks.filter((link) =>

getHashId(link.getAttribute("href"))

)

if (linksWithHash.length === 0) return false


const newTargets = linksWithHash

.map((link) => {

const id = getHashId(link.getAttribute("href"))

const el = id ? document.getElementById(id) : null

return el

? { id: id as string, el: el, link: link }

: null

})

.filter(function (t) {

return Boolean(t)

}) as {

id: string

el: HTMLElement

link: HTMLAnchorElement

}[]


log("setupLinks", {

linksWithHashCount: linksWithHash.length,

newTargetsCount: newTargets.length,

ids: newTargets.map((t) => t.id),

})


if (newTargets.length === 0) return false

targets = newTargets


linksWithHash.forEach((link) => {

link.style.transition =

"color 0.15s ease, font-weight 0.15s ease"

})


if (!didInitialHashScroll) {

didInitialHashScroll = true

const initialId = getHashId(window.location.hash)

const initialTarget = initialId

? targets.find((t) => t.id === initialId)

: null

if (initialTarget) {

lastActive = initialTarget.id

setActiveHref(initialTarget.id)

window.requestAnimationFrame(() => {

scrollToTarget(initialTarget.el, "auto")

})

}

}


return true

}


const clickHandler = (e: MouseEvent) => {

const targetEl = e.target as HTMLElement

const link = targetEl.closest("a") as HTMLAnchorElement | null

if (!link) {

return

}

const id = getHashId(link.getAttribute("href"))

log("click", { id, targetsCount: targets.length, targetIds: targets.map(t=>t.id) })

if (!id) return

const el = document.getElementById(id)

if (!el) {

log("click: no el")

return

}

if (!targets.some((t) => t.id === id)) {

log("click: id not in targets, aborting")

return

}

e.preventDefault()

e.stopImmediatePropagation()

lastActive = id

setActiveHref(id)

scrollToTarget(el, "smooth")

}

window.addEventListener("click", clickHandler, true)


const applyStyles = (activeId: string | null) => {

const allLinks = Array.from(

document.querySelectorAll("a")

) as HTMLAnchorElement[]

allLinks.forEach((link) => {

const id = getHashId(link.getAttribute("href"))

if (!id) return

if (!targets.some((t) => t.id === id)) return

const isActive = id === activeId

link.style.color = isActive ? "#111111" : "#9a9a9a"

link.style.fontWeight = isActive ? "500" : "400"

})

}


const updateActive = () => {

if (targets.length === 0) {

setupLinks()

if (targets.length === 0) return

}

let bestId: string | null = null

let bestTop = -Infinity

for (let i = 0; i < targets.length; i++) {

const t = targets[i]

if (!t.el.isConnected) continue

const top = t.el.getBoundingClientRect().top

if (top <= THRESHOLD && top > bestTop) {

bestTop = top

bestId = t.id

}

}

if (!bestId) bestId = targets[0].id

if (bestId !== lastActive) {

lastActive = bestId

setActiveHref(bestId)

}

applyStyles(bestId)

}

const intervalId = window.setInterval(updateActive, 150)

updateActive()


return () => {

log("effect cleanup / unmount")

window.clearInterval(intervalId)

window.removeEventListener("click", clickHandler, true)

}

}, [])


return <Component ref={ref} {...props} />

})

}

import { forwardRef, useEffect, useState } from "react"

import type { ComponentType } from "react"


export function ScrollSpyToc(Component): ComponentType {

return forwardRef((props, ref) => {

const [activeHref, setActiveHref] = useState<string | null>(null)


useEffect(() => {

const THRESHOLD = 160

// Extra breathing room (in px) left above a section's hash

// target when scrolling to it, so the sticky top nav doesn't

// cover the eyebrow label + title.

const OFFSET = 140

let lastActive: string | null = null

let targets: {

id: string

el: HTMLElement

link: HTMLAnchorElement

}[] = []


const getHashId = (href: string | null) => {

if (!href) return null

const i = href.lastIndexOf("#")

if (i === -1) return null

const id = href.slice(i + 1)

return id || null

}


const scrollToTarget = (

el: HTMLElement,

behavior: ScrollBehavior

) => {

const top =

el.getBoundingClientRect().top + window.scrollY - OFFSET

window.scrollTo({ top: Math.max(top, 0), behavior })

}


const setupLinks = () => {

const allLinks = Array.from(

document.querySelectorAll("a")

) as HTMLAnchorElement[]

const linksWithHash = allLinks.filter((link) =>

getHashId(link.getAttribute("href"))

)

if (linksWithHash.length === 0) return false


const newTargets = linksWithHash

.map((link) => {

const id = getHashId(link.getAttribute("href"))

const el = id ? document.getElementById(id) : null

return el

? { id: id as string, el: el, link: link }

: null

})

.filter(function (t) {

return Boolean(t)

}) as {

id: string

el: HTMLElement

link: HTMLAnchorElement

}[]


if (newTargets.length === 0) return false

targets = newTargets


linksWithHash.forEach((link) => {

link.style.transition =

"color 0.15s ease, font-weight 0.15s ease"

})


return true

}


const applyStyles = (activeId: string | null) => {

const allLinks = Array.from(

document.querySelectorAll("a")

) as HTMLAnchorElement[]

allLinks.forEach((link) => {

const id = getHashId(link.getAttribute("href"))

if (!id) return

if (!targets.some((t) => t.id === id)) return

const isActive = id === activeId

link.style.color = isActive ? "#111111" : "#9a9a9a"

link.style.fontWeight = isActive ? "500" : "400"

})

}


// Rather than trying to intercept the ToC link's click (Framer's

// own internal link/router click handling runs first and stops

// propagation before any of our own listeners -- window-capture

// included -- ever see the event, so that approach cannot win),

// this instead just watches location.hash on every poll tick.

// Whichever handler updates the hash (Framer's own, or a plain

// native anchor click, or the user editing the URL bar), the

// instant we see it change to a hash we recognize, we override

// the scroll position with our own offset-compensated one. This

// is robust regardless of how the hash change was triggered.

let lastSeenHash = getHashId(window.location.hash)

let didInitialScroll = false


const handleHash = (behavior: ScrollBehavior) => {

const id = getHashId(window.location.hash)

if (id === lastSeenHash && didInitialScroll) return

lastSeenHash = id

didInitialScroll = true

if (!id) return

const target = targets.find((t) => t.id === id)

if (!target) return

lastActive = id

setActiveHref(id)

scrollToTarget(target.el, behavior)

}


const updateActive = () => {

if (targets.length === 0) {

setupLinks()

if (targets.length === 0) return

}


handleHash(didInitialScroll ? "smooth" : "auto")


let bestId: string | null = null

let bestTop = -Infinity

for (let i = 0; i < targets.length; i++) {

const t = targets[i]

if (!t.el.isConnected) continue

const top = t.el.getBoundingClientRect().top

if (top <= THRESHOLD && top > bestTop) {

bestTop = top

bestId = t.id

}

}

if (!bestId) bestId = targets[0].id

if (bestId !== lastActive) {

lastActive = bestId

setActiveHref(bestId)

}

applyStyles(bestId)

}

const intervalId = window.setInterval(updateActive, 100)

updateActive()


return () => {

window.clearInterval(intervalId)

}

}, [])


return <Component ref={ref} {...props} />

})

}