offset shifts where a view is drawn without changing where the layout system thinks it is. Everything around it stays put. That makes it the right tool for motion and the wrong tool for layout.

Layout is unchangedoffset does not reserve space
HStack {
    Circle().frame(width: 20, height: 20).offset(y: -30)
    Circle().frame(width: 20, height: 20)
}
// The HStack is still 20pt tall. The first circle is simply drawn above it.

If you want the surrounding layout to react, animate padding, frame or the stack's alignment instead. If you want a thing to move across what's already there, offset.

A wave

Five dots, one shared state, and a delay per index. Each dot runs the same easeInOut between two offsets, started a little later than the last.

DotWave.swiftplays as written
import SwiftUI

struct DotWave: View {
    @State private var up = false
    var count: Int = 5
    var period: Double = 1.05

    var body: some View {
        HStack(spacing: 7) {
            ForEach(0..<count, id: \.self) { i in
                Circle()
                    .fill(.cyan)
                    .frame(width: 9, height: 9)
                    .offset(y: up ? -9 : 9)
                    .animation(
                        .easeInOut(duration: period / 2)
                            .repeatForever()
                            .delay(Double(i) * period / 10),
                        value: up
                    )
            }
        }
        .onAppear { up = true }
    }
}

A real wave, from sin

The version above approximates a wave with staggered easing. If you want an actual sine — where the vertical position is a genuine function of index and time — compute it:

Sine waveplays as written
import SwiftUI

struct SineWave: View {
    var count: Int = 7
    var amplitude: Double = 10
    var period: Double = 1.4

    var body: some View {
        TimelineView(.animation) { context in
            let t = context.date.timeIntervalSinceReferenceDate
            HStack(spacing: 7) {
                ForEach(0..<count, id: \.self) { i in
                    Circle()
                        .fill(.teal)
                        .frame(width: 9, height: 9)
                        .offset(y: sin(t * .pi * 2 / period + Double(i) * 0.6) * amplitude)
                }
            }
        }
    }
}

No state, no delays, no onAppear. The phase offset Double(i) * 0.6 is what makes it travel along the row, and changing that one number changes the wavelength.

Bouncing

Offset plus a spring, and the overshoot does the work:

BouncingBall.swiftplays as written
import SwiftUI

struct BouncingBall: View {
    @State private var down = false

    var body: some View {
        ZStack(alignment: .top) {
            Circle()
                .fill(.pink)
                .frame(width: 18, height: 18)
                .offset(y: down ? 28 : 0)
        }
        .frame(width: 44, height: 48)
        .animation(
            .spring(response: 0.36, dampingFraction: 0.55).repeatForever(),
            value: down
        )
        .onAppear { down = true }
    }
}

offset, position or padding

ModifierRelative toAffects layout
offset(x:y:)Where it would have beenNo
position(x:y:)The parent's coordinate spaceNo — and it takes all the space
padding()Its own edgesYes

position catches people out: it makes the view fill its parent and then places the content at that point, so a position inside an HStack will happily eat the whole stack.

Order, again

offset before rotationEffect swings the view around its parent's centre at a radius. After, it slides the already-rotated view along the screen's axes. Both are useful; they are not interchangeable. There is more on that in rotation animation.

Run it

Open SineWave in Swoop and change the phase step from 0.6 to 0.15, then to 2.0. It goes from a gentle ripple to something closer to a scatter, and the number stops being arbitrary.