That effect is not specific to CardView
, but rather is an artifact of the method Android uses to render and transform material shadows on all View
s since Lollipop and the introduction of Material Design. It can be observed on pretty much any View
with the right attributes:
- A translucent/transparent background,
- A positive z-offset (
elevation
+ translationZ
),
- A
ViewOutlineProvider
that sets an Outline
with a non-zero alpha.
To illustrate, this mock-up shows a plain <View>
with a background that is a solid white round rectangle tinted with a translucent blue:

Tower image modified from "Eiffel Tower in Vintage Sepia" by Lenny K Photography, licensed under CC BY 2.0.
Obviously, the stated criteria present a few different ways to disable the shadow altogether, so if you don't really need that but still need the elevation for z-ordering, for instance, you could simply set the ViewOutlineProvider
to null. Or, if perhaps you also need the outline for clipping, you could implement a ViewOutlineProvider
to set an Outline
with zero alpha. However, if you need all of these things just without the shadow glitch, it seems that a workaround will be required since the platform apparently offers no way to fix that otherwise.*
This answer was originally a handful of "last resort"-type workarounds that were put together under my initial (incorrect) presumption that, except for a few high-level attributes, the shadows API was essentially inaccessible from the SDK. I cannot rightly recommend those old approaches anymore, but the overall method that I ended up with is a bit more complex than I'd intended for this post. However, neither can I rightly turn this answer into just an advertisement for the utility library I've put together from all of this, so I'm going to demonstrate the two core techniques I use to obtain clipped shadows, to give you something that you can get your hands on here, if you'd rather not fiddle with some shady stranger's unvetted GitHub repo.
* After I'd figured out a decently robust technique for the general case, I created this post in order to share those findings. The question there has my reasoning regarding the platform statement, along with links to several other Stack Overflow posts with the same core issue, and the same lack of an actual fix.
Overview
Though the native shadows are pretty limited, Android's procedure for calculating and rendering them is relatively complex: two light sources are considered, an ambient and a spot; the shadows must adjust for any transformations that their View
s might undergo, like scaling and rotation; newer versions support separate colors for each light source, per View
; etc. For these and other reasons, I decided that it would be preferable to somehow copy the native shadows and clip those, rather than trying to draw correct ones ourselves from scratch.
This leads to the reason for the two separate techniques: the intrinsic shadows are a resultant property of the RenderNode
class, but that's not available in the SDK until API level 29 (docs). There are ways to use the equivalent class on older versions, but those are outside the scope of this answer, so an alternate method that uses empty View
s is shown here to cover all relevant Android versions in these hands-on samples. The library uses this empty View
method as a fallback, should there be any problems using RenderNode
s on API levels 28 and below.
For the sake of illustration, we will use the following simplified layout, which we'll imagine ends up looking exactly like the example image above:
<FrameLayout
android:id="@+id/parent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/tower">
<View
android:id="@+id/target"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="20dp"
android:background="@drawable/shape_round_rectangle"
android:backgroundTint="#602B608A"
android:elevation="15dp"
android:outlineProvider="background" />
</FrameLayout>
The tower
image is available here, and the shape_round_rectangle
drawable is simply:
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFFFF" />
<corners android:radius="@dimen/corner_radius" />
</shape>
Where corner_radius
is 2dp
, in case you want your tests to look similar to the images here for comparison purposes. These values are all incidental, however, and you can use whatever you like, really.
Each example assumes that parent
and target
are assigned to appropriate val
s of the same names; e.g.:
val parent = findViewById<ViewGroup>(R.id.parent)
val target = findViewById<View>(R.id.target)
We're going to draw our clipped shadows in parent
's ViewGroupOverlay
, as this seems the most straightforward way to compactly demonstrate a general approach. Technically, this should work anywhere that you have access to a hardware-accelerated Canvas
– e.g., in a custom ViewGroup
's dispatchDraw()
override – though it may need some refactoring to apply it elsewhere.
Before beginning with the RenderNode
section, it should be noted that each technique needs three basic things:
- An
Outline
object to describe the shadow's shape,
- A
Path
object of the same shape, to clip out the interior,
- Some mechanism by which to draw the shadow.
To that end, there is quite a bit of repetition across the two versions in order to keep them straightforward and plainly explanatory. You can obviously rearrange and consolidate things as you see fit, should you be implementing these side by side.
Method #1: RenderNode
shadows
(For the sake of brevity, this section assumes Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
where needed.)
For the RenderNode
version on API levels 29 and up, Canvas
offers the drawRenderNode()
function, so we can handle the clipping and drawing both directly. As mentioned, we're doing this in the parent's overlay, and we can get access to the Canvas
there with a simple Drawable
subclass:
@RequiresApi(Build.VERSION_CODES.Q)
class ClippedShadowDrawable : Drawable() {
private val outline = Outline().apply { alpha = 1.0F }
private val clipPath = Path()
private val renderNode = RenderNode("ClippedShadowDrawable")
fun update(
left: Int, top: Int, right: Int, bottom: Int,
radius: Float, elevation: Float
) {
setBounds(left, top, right, bottom)
clipPath.rewind()
clipPath.addRoundRect(
left.toFloat(),
top.toFloat(),
right.toFloat(),
bottom.toFloat(),
radius,
radius,
Path.Direction.CW
)
outline.setRoundRect(0, 0, right - left, bottom - top, radius)
renderNode.setOutline(outline)
renderNode.setPosition(left, top, right, bottom)
renderNode.elevation = elevation
invalidateSelf()
}
override fun draw(canvas: Canvas) {
if (!canvas.isHardwareAccelerated) return
canvas.save()
canvas.enableZ()
canvas.clipOutPath(clipPath)
canvas.drawRenderNode(renderNode)
canvas.disableZ()
canvas.restore()
}
// Unused
override fun getOpacity() = PixelFormat.TRANSLUCENT
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: ColorFilter?) {}
}
In addition to the aforementioned Outline
and Path
objects, we use a RenderNode
here for the actual shadow draw. We also define an update()
function that takes relevant values from the target View
as a simple way to refresh those properties as needed.
You can see that the magic happens in the draw()
override, and I think the code there explains itself quite clearly, though I will mention that the en/disableZ()
functions basically tell the underlying native routine that the RenderNode
should cast a shadow, if it qualifies.
To put it to use, we'll wire it up to the parent
and target
described in the Overview section above like so:
val clippedShadowDrawable = ClippedShadowDrawable()
target.setOnClickListener {
if (target.tag != clippedShadowDrawable) {
target.outlineProvider = null
parent.overlay.add(clippedShadowDrawable)
clippedShadowDrawable.update(
target.left,
target.top,
target.right,
target.bottom,
resources.getDimension(R.dimen.corner_radius),
target.elevation
)
target.tag = clippedShadowDrawable
} else {
target.outlineProvider = ViewOutlineProvider.BACKGROUND
parent.overlay.remove(clippedShadowDrawable)
target.tag = null
}
}
The OnClickListener
on the target
will allow you to easily toggle the fix in order to compare and contrast its effect. We set the target
's tag
to the ClippedShadowDrawable
as a convenient flag to indicate whether the fix is currently enabled, hence the if (target.tag != clippedShadowDrawable)
check first thing.
To turn the fix on, we need to:
- Set the
target
's ViewOutlineProvider
to null, to disable the intrinsic shadow,
- Add our custom
Drawable
to the parent
's overlay,
- Call its
update()
function,
- Set the
target
's tag
to the ClippedShadowDrawable
instance as our on flag.
We turn it off by:
- Restoring the original
ViewOutlineProvider
on the target
,
- Removing our custom
Drawable
from the parent
's overlay,
- Setting the
tag
back to null
, flagging it as off.
That's all there is to it, but remember that this is demonstrating only the very basic core technique. The static setup and known values make accounting for the target
's state trivial, but things get complicated quickly if you need additional behaviors like adjusting for button presses or animations.
Method #2: View
shadows
(Though this one works on all applicable versions, these shadows didn't exist before Lollipop, so you might need some SDK_INT
checks for that.)
This approach is a little roundabout. Since we don't have direct access to RenderNode
s here, we instead use an empty View
for its intrinsic shadow. Unfortunately, we can't just directly draw()
a View
ourselves and have its shadow work as we would like, so we handle this somewhat passively: we add the empty View
to a parent ViewGroup
to let it draw via the normal routine while we clip and restore around that in the parent's dispatchDraw()
.
To that end, our ClippedShadowView
itself is actually a custom ViewGroup
with another View
inside:
class ClippedShadowView(context: Context) : ViewGroup(context) {
private val outline = Outline().apply { alpha = 1.0F }
private val clipPath = Path()
private val shadowView = View(context)
init {
addView(shadowView)
shadowView.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.set(this@ClippedShadowView.outline)
}
}
}
fun update(
left: Int, top: Int, right: Int, bottom: Int,
radius: Float, elevation: Float
) {
clipPath.rewind()
clipPath.addRoundRect(
left.toFloat(),
top.toFloat(),
right.toFloat(),
bottom.toFloat(),
radius,
radius,
Path.Direction.CW
)
outline.setRoundRect(0, 0, right - left, bottom - top, radius)
shadowView.layout(left, top, right, bottom)
shadowView.elevation = elevation
shadowView.invalidate()
}
override fun dispatchDraw(canvas: Canvas) {
if (!canvas.isHardwareAccelerated) return
canvas.save()
clipOutPath(canvas, clipPath)
super.dispatchDraw(canvas)
canvas.restore()
}
private fun clipOutPath(canvas: Canvas, path: Path) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
canvas.clipOutPath(path)
} else {
@Suppress("DEPRECATION")
canvas.clipPath(path, Region.Op.DIFFERENCE)
}
}
// Unused
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
}
Unfortunately, there's no way to simply set an Outline
on a View
, so we have to use a custom ViewOutlineProvider
on the shadowView
to deliver it through that mechanism instead. After the Outline
is refreshed in update()
, the shadowView
is invalidated, which will cause it to call through to the provider for the updated Outline
.
In the dispatchDraw()
override, we clip out the Path
before calling the super
function to let ViewGroup
draw the shadowView
as it normally would.
We wire this up in almost exactly the same way as the RenderNode
setup, but with an additional call to clippedShadowView.layout()
, since that's not laid out anywhere else:
val clippedShadowView = ClippedShadowView(target.context)
target.setOnClickListener {
if (target.tag != clippedShadowView) {
target.outlineProvider = null
parent.overlay.add(clippedShadowView)
clippedShadowView.layout(0, 0, parent.width, parent.height)
clippedShadowView.update(
target.left,
target.top,
target.right,
target.bottom,
resources.getDimension(R.dimen.corner_radius),
target.elevation
)
target.tag = clippedShadowView
} else {
target.outlineProvider = ViewOutlineProvider.BACKGROUND
parent.overlay.remove(clippedShadowView)
target.tag = null
}
}
Aftermath
Both methods result in exactly the same thing, taking into consideration the fact that shadows will vary slightly with their screen positions, due to the two-source rendering model:

The top image shows the RenderNode
fix in action, the middle is the View
method, and the bottom one is what either would look like with a completely transparent target (or no target at all, actually) to make it easier to see exactly how the shadow is being clipped.