Skip to content

Pixelate Shader Effect

A view modifier that draws a view in big square blocks, like a spoiler, and brings it into focus as they shrink, drawn by a Metal shader.

Pixelate Shader Effect, playing
  • Effects
  • iOS 18.0+
  • intermediate
  • Metal shader
Download .swiftDownload .metal
Origin
Original work
Found by
pixelate · mosaic · spoiler · censor · shader · metal · reveal · pixelar · mosaico · spoiler · censurar · shader · metal · revelar

Draws this view in big square blocks, like a censored picture, by a Metal shader. amount goes from 0, untouched, to 1, in blocks of blockSize points, and animates: the blocks shrink until the view comes into focus.

A shader draws what SwiftUI draws itself: a view backed by UIKit, such as a map, shows a placeholder.

It only changes how the view looks: hide it from VoiceOver yourself while it is a secret. With Reduce Motion on, the view goes from blocks to sharp in one step.

struct PixelateShaderEffectDemo: View {
@State private var isHidden = true
var body: some View {
VStack(spacing: 24) {
VStack(alignment: .leading, spacing: 10) {
ZStack {
LinearGradient(colors: [.indigo, .purple, .orange], startPoint: .topLeading, endPoint: .bottomTrailing)
Image(systemName: "trophy.fill")
.font(.system(size: 84))
.foregroundStyle(.yellow.gradient)
.shadow(radius: 8)
}
.frame(height: 220)
.clipShape(.rect(cornerRadius: 18))
Text("Season finale")
.font(.title2.bold())
Text("Rivera wins the final in the last minute, after trailing for the whole second half.")
.font(.body)
.foregroundStyle(.secondary)
}
.padding(16)
.background(.background, in: .rect(cornerRadius: 26))
.pixelate(isHidden ? 1 : 0)
.accessibilityElement(children: .combine)
.accessibilityHidden(isHidden)
Button(isHidden ? "Reveal Spoiler" : "Hide Again", systemImage: isHidden ? "eye.fill" : "eye.slash.fill") {
withAnimation(.easeInOut(duration: 0.8)) { isHidden.toggle() }
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
}
}

pixelate(_:blockSize:)

ParameterTypeDefault
amountDoubleRequired
blockSizeCGFloat22

Copy both files into your project: the Swift file and the Metal shader it draws with. Or download PixelateShaderEffect.swift and PixelateShaderEffect.metal.

PixelateShaderEffect.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated enum PixelateShaderEffectMath {
/// The side of the blocks for an amount of pixelation: one point, which changes nothing, at
/// 0, and `maximum` at 1.
static func blockSize(amount: Double, maximum: Double) -> Double {
guard amount.isFinite else { return 1 }
return 1 + (max(maximum, 1) - 1) * min(max(amount, 0), 1)
}
/// The center of the block a point falls in, which gives its color to the whole block.
/// The shader does the same sum.
static func blockCenter(of point: CGPoint, size: Double) -> CGPoint {
guard size > 1 else { return point }
return CGPoint(x: ((point.x / size).rounded(.down) + 0.5) * size, y: ((point.y / size).rounded(.down) + 0.5) * size)
}
/// The function in `PixelateShaderEffect.metal`, with its arguments in the order it declares them.
static func shader(size: Double, bounds: CGSize) -> Shader {
ShaderLibrary.PixelateShaderEffectBlocks(.float(size), .float2(bounds))
}
}
extension View {
/// Draws this view in big square blocks, like a censored picture, by a Metal shader.
/// `amount` goes from 0, untouched, to 1, in blocks of `blockSize` points, and animates:
/// the blocks shrink until the view comes into focus.
///
/// - Important: Copy two files into the same target: `PixelateShaderEffect.swift` and `PixelateShaderEffect.metal`.
///
/// A shader draws what SwiftUI draws itself: a view backed by UIKit, such as a map, shows a placeholder.
///
/// It only changes how the view looks: hide it from VoiceOver yourself while it is a secret.
/// With Reduce Motion on, the view goes from blocks to sharp in one step.
func pixelate(_ amount: Double, blockSize: CGFloat = 22) -> some View {
modifier(PixelateShaderEffectModifier(amount: amount, blockSize: blockSize))
}
}
/// `Animatable`, so the shader gets every step of the change: a shader's arguments do not animate by themselves.
private struct PixelateShaderEffectModifier: ViewModifier, Animatable {
var amount: Double
let blockSize: CGFloat
@Environment(\.accessibilityReduceMotion) private var reduceMotion
nonisolated var animatableData: Double {
get { amount }
set { amount = newValue }
}
func body(content: Content) -> some View {
// With Reduce Motion the blocks do not shrink step by step: they are there or they are not.
let shown = reduceMotion ? (amount > 0.5 ? 1 : 0) : amount
let size = PixelateShaderEffectMath.blockSize(amount: shown, maximum: blockSize)
content
// `visualEffect` gives the shader the size of the view, which it needs at the edges.
.visualEffect { view, proxy in
view.layerEffect(
PixelateShaderEffectMath.shader(size: size, bounds: proxy.size),
maxSampleOffset: CGSize(width: size, height: size),
isEnabled: size > 1
)
}
// The first use of a shader waits for it to compile. Doing that now keeps the first frames smooth.
.task {
try? await PixelateShaderEffectMath.shader(size: blockSize, bounds: .zero).compile(as: .layerEffect)
}
}
}
// MARK: - Demo
struct PixelateShaderEffectDemo: View {
@State private var isHidden = true
var body: some View {
VStack(spacing: 24) {
VStack(alignment: .leading, spacing: 10) {
ZStack {
LinearGradient(colors: [.indigo, .purple, .orange], startPoint: .topLeading, endPoint: .bottomTrailing)
Image(systemName: "trophy.fill")
.font(.system(size: 84))
.foregroundStyle(.yellow.gradient)
.shadow(radius: 8)
}
.frame(height: 220)
.clipShape(.rect(cornerRadius: 18))
Text("Season finale")
.font(.title2.bold())
Text("Rivera wins the final in the last minute, after trailing for the whole second half.")
.font(.body)
.foregroundStyle(.secondary)
}
.padding(16)
.background(.background, in: .rect(cornerRadius: 26))
.pixelate(isHidden ? 1 : 0)
.accessibilityElement(children: .combine)
.accessibilityHidden(isHidden)
Button(isHidden ? "Reveal Spoiler" : "Hide Again", systemImage: isHidden ? "eye.fill" : "eye.slash.fill") {
withAnimation(.easeInOut(duration: 0.8)) { isHidden.toggle() }
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
}
}
#Preview {
PixelateShaderEffectDemo()
}
PixelateShaderEffect.metal
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
// For SwiftUI's `layerEffect`. Draws the view in square blocks of `size`
// points, each one in the color of the point at its center. A block that
// sticks out of the view, which is `bounds` in size, takes the color of the
// last point inside, so the edges are not cut. A size of one point or less
// leaves the view as it is.
[[ stitchable ]] half4 PixelateShaderEffectBlocks(
float2 position,
SwiftUI::Layer layer,
float size,
float2 bounds
) {
if (size <= 1.0) {
return layer.sample(position);
}
float2 center = (floor(position / size) + 0.5) * size;
return layer.sample(min(center, bounds - 0.5));
}