TiltCarousel - 3D carousel effect

You’ve seen the effect all over the place—here’s an easy way to do it in Codea. Horizontal image stack that can be swiped side-to-side to change images.

Zip file:
TiltCarousel.zip (50.5 KB)

I’ve been thinking it’s good to share code directly in the forums more too, because it helps LLMs write better Codea.

So here’s the code, all easy to copy/paste:

--# Main
local function makeCard(label, r, g, b)
  local iw, ih  = 400, 560
  local img     = image(iw, ih)
  local SIZE    = 52
  setContext(img)
  background(r, g, b)
  pushStyle()
  noStroke()
  fill(0, 0, 0, 30)
  rectMode(CORNER)
  pushMatrix()
  translate(iw / 2, ih / 2)
  rotate(45)
  local span = math.ceil(math.sqrt(iw*iw + ih*ih) / SIZE) + 2
  rectMode(CENTER)
  for row = -span, span do
    for col = -span, span do
      if (row + col) % 2 == 0 then rect(col*SIZE, row*SIZE, SIZE, SIZE) end
    end
  end
  popMatrix()
  fill(255, 255, 255, 210)
  font("HelveticaNeue-Bold")
  fontSize(130)
  textMode(CENTER)
  text(label, iw / 2, ih / 2)
  popStyle()
  setContext()
  return img
end

function setup()
  viewer.mode = FULLSCREEN
  
  parameter.number("cardSpacing", 0, 2, 0.17)
  parameter.number("centerSpacing", 0, 2, 0.26)
  parameter.number("tiltAway", -90, 90, -90)
  parameter.number("centerZ", -5, 5, -0.05)
  
  local imgs = {}
  for i = 1, 30 do
    local img = image(400, 600)
    setContext(img)
    -- Draw a random colored background for each card
    background(math.random(50,200), math.random(50,200), math.random(50,200))
    fill(255)
    fontSize(100)
    text(tostring(i), 200, 300)
    setContext()
    table.insert(imgs, img)
  end
  
  local h = HEIGHT / 3
  carousels = {
    TiltCarousel(imgs, {centerY = h * 2.5, height = HEIGHT * 0.3}),
    TiltCarousel(imgs, {centerY = h * 1.5, height = HEIGHT * 0.3}),
    TiltCarousel(imgs, {centerY = h * 0.5, height = HEIGHT * 0.3})
  }
end

function draw()
  background(26, 24, 32)
  
  for _, c in ipairs(carousels) do
    -- Syncing the live parameters to the carousel instances
    c.cardSpacing = cardSpacing
    c.centerSpacing = centerSpacing
    c.tiltAway = tiltAway
    c.centerZ = centerZ
    
    c:draw()
  end
end


function touched(t)
  for i = #carousels, 1, -1 do
    if carousels[i]:touched(t) then return end
  end
end
--# TiltCarousel
--# TiltCarousel
TiltCarousel = class()

function TiltCarousel:init(images, opts)
  self.cards    = {}
  self.index    = 1.0
  self.target   = 1.0
  self.dragging = false
  self.prevX    = 0
  self.velX     = 0
  
  opts = opts or {}
  self.centerY = opts.centerY or 0
  self.centerX = opts.centerX or (WIDTH / 2)
  self.height  = opts.height  or (HEIGHT * 0.3)
  self.width   = opts.width   or WIDTH
  self.fov     = opts.fov     or 45
  
  self.tx = image(math.ceil(self.width), math.ceil(self.height))
  
  -- Variables synced from Main parameters
  self.cardSpacing   = 0.17
  self.centerSpacing = 0.26
  self.tiltAway      = -90
  self.centerZ       = -0.05
  self.SNAP_SPEED    = 10
  
  for i, img in ipairs(images) do
    self.cards[i] = TiltCard(img)
  end
end

function TiltCarousel:draw()
  local n = #self.cards
  if n == 0 then return end
  
  clip()  -- defensive: inherited clip would survive setContext and kill the tx
  
  if not self.dragging then
    self.index = self.index + (self.target - self.index) * self.SNAP_SPEED * DeltaTime
  end
  
  setContext(self.tx)
  background(0, 0, 0, 0)
  
  local aspect = self.cards[1].img.width / self.cards[1].img.height
  local camZ = 1.0 / (2.0 * math.tan(math.rad(self.fov * 0.5)))
  
  pushMatrix()
  perspective(self.fov, self.tx.width / self.tx.height)
  camera(0, 0, camZ, 0, 0, 0, 0, 1, 0)
  
  local cardW = 1.0 * aspect
  
  local visible = {}
  for i = 1, n do
    local dist = i - self.index
    local absDist = math.abs(dist)
    local zFactor = math.max(0, 1.0 - absDist) 
    local wz = zFactor * self.centerZ
    local wx, tilt = 0, 0
    if absDist > 0 then
      local sign = dist > 0 and 1 or -1
      if absDist <= 1 then
        wx = dist * (cardW * self.centerSpacing)
      else
        wx = sign * (cardW * self.centerSpacing) + (dist - sign) * (cardW * self.cardSpacing)
      end
      tilt = math.max(-1, math.min(1, dist)) * self.tiltAway
    end
    visible[#visible + 1] = {
      card = self.cards[i], wx = wx, wz = wz,
      tilt = tilt, absDist = absDist, dist = dist
    }
  end
  
  table.sort(visible, function(a, b) return a.absDist > b.absDist end)
  
  for _, e in ipairs(visible) do
    local pivot = (e.dist < 0 and 0.0) or (e.dist > 0 and 1.0) or 0.5
    e.card.worldX = e.wx
    e.card.worldZ = e.wz
    e.card.worldH = 1.0 
    e.card:draw(pivot, e.tilt)
  end
  
  popMatrix()
  setContext()
  ortho()
  viewMatrix(matrix())
  
  spriteMode(CENTER)
  sprite(self.tx, self.centerX, self.centerY)
end



function TiltCarousel:touched(t)
  local n = #self.cards
  if n == 0 then return false end
  local halfH = self.height * 0.5
  if t.state == BEGAN then
    if t.y < (self.centerY - halfH) or t.y > (self.centerY + halfH) then return false end
    self.dragging, self.prevX, self.velX = true, t.x, 0
    return true
  end
  if not self.dragging then return false end
  
  local stepPx = (self.height * (self.cards[1].img.width / self.cards[1].img.height)) * self.cardSpacing
  if t.state == MOVING then
    local dx = t.x - self.prevX
    self.index = math.max(1, math.min(n, self.index - dx / stepPx))
    self.target, self.velX, self.prevX = self.index, dx, t.x
    return true
  elseif t.state == ENDED or t.state == CANCELLED then
    self.dragging = false
    local nudge = -self.velX / stepPx * 0.25
    self.target = math.max(1, math.min(n, math.floor(self.index + nudge + 0.5)))
    return true
  end
  return false
end








--# TiltCard
--# TiltCard
TiltCard = class()

-- Static defaults for standalone use
TiltCard.FOV = 45
TiltCard.CAM_Z = 4.0

function TiltCard:init(img)
  self.img        = img
  self.worldX     = 0
  self.worldY     = 0
  self.worldZ     = 0
  self.worldH     = 1.0
  self.brightness = 255
  
  self._mesh = mesh()
  self._mesh.texture = img
  self._mesh.texCoords = {
    vec2(0,0), vec2(1,0), vec2(1,1), 
    vec2(0,0), vec2(1,1), vec2(0,1)
  }
end

function TiltCard:draw(pivotFrac, tilt)
  pivotFrac = pivotFrac or 0.5
  local aspect = self.img.width / self.img.height
  local w = self.worldH * aspect
  local h = self.worldH
  
  -- Update mesh vertices based on current calculated height
  local hw, hh = w * 0.5, h * 0.5
  self._mesh.vertices = {
    vec3(-hw,-hh,0), vec3( hw,-hh,0), vec3( hw, hh,0),
    vec3(-hw,-hh,0), vec3( hw, hh,0), vec3(-hw, hh,0)
  }
  
  local b = self.brightness
  self._mesh:setColors(color(b, b, b, 255))
  
  -- Pivot Logic: Offset the translation so rotation happens at the edge
  local pivotOff = (pivotFrac - 0.5) * w
  
  pushMatrix()
  translate(self.worldX + pivotOff, self.worldY, self.worldZ)
  rotate(tilt or 0, 0, 1, 0)
  translate(-pivotOff, 0, 0)
  self._mesh:draw()
  popMatrix()
end

-- Placeholder for interaction if needed
function TiltCard:touched(t)
  return false
end
1 Like

Makes me miss my first iPod touch. When you rotated to landscape it did the carousel album art view. What happened to software!

I believe that view is still available in Mac OS Finder

:person_shrugging:

Not the same though I know

Remember how proud Apple was of that view when they first rolled it out? It was the first image in all the announcements of the new OS at the time.

1 Like

@Steppers this also could not be uploaded to WebRepo.

You said you’re currently busy–do you want me to stop notifying you about these things for the moment?

I think the 3D effect is gone in the Finder now though, it’s a scrolling list of thumbnails.

Though to be fair, macOS 26 / iOS 26 have some pretty crazy visual effects. Seeing a Liquid Glass panel reflect a photo on the other side of the screen along its rim is just incredible control over the software stack.