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} />
})
}