First thing is to use distance field font rendering and pack every glyph into a single texture using all 4 channels as individual gray-scale textures. This allows you to use only 1 font size in your atlas.
It looks like this inside the texture when tightly packed:
You can pack a ridiculous amount of characters at very low resolution and they still look great when scaled up. You can see the little distortions around the corners:
The font in the atlas in the above example is only 24 pixels high.
This gives you space for over 20,000 glyphs (or ~10,000 Asian glyphs as they need a bit more definition) in a single 2048x2048x32bpp texture.
This eliminates the need for multiple sizes of the same font in the font atlas texture.
The next step is to assign ALL your UI textures atlases to a single pixel shader that will sample all texture atlases but only effectively use 1 texture / texture plane out of all the textures.
You can use a UV encoding to let the pixel shader know which textures to throw out and which to use: eg: U range [-2..-1), [-1..0), [0..1), and so on..
Make sure you sample all the textures: conditional/dependent texture sampling hurts GPU performance more than sampling excessively (within reason)
// sample all the textures at the same time
vec4 glyph_combined = texture(glyph_texture, uv.xy);
vec4 ui_graphics1 = texture(ui_graphics_texture1, uv.xy);
vec4 ui_graphics2 = texture(ui_graphics_texture2, uv.xy);
vec4 ui_graphics3 = texture(ui_graphics_texture3, uv.xy);
// DecodeDistanceFieldMagic is outside the scope of this question
// look up distance field font rendering linked at the top of the answer
vec4 glyph = DecodeDistanceFieldMagic(glyph_combined);
// uv.x < 1 selects glyph, uv.x >= 1 selects gfx 1
vec4 glyph_or_gfx1 = mix(glyph, ui_graphics1, step(1.0, uv.x));
// uv.x < 3 selects gfx 2, uv.x >= 3 selects gfx 3
vec4 gfx2_or_gfx3 = mix(ui_graphics2, ui_graphics3, step(3.0, uv.x));
// uv.x < 2 selects first "pair", uv.x >= 2 selects second "pair"
vec4 last_selection = mix(glyph_or_gfx1, gfx2_or_gfx3, step(2.0, uv.x));
output = last_selection * vertex_color;
Make sure to set all your textures to repeat.
With this method each individual texture can still have their own filtering.
If the graphic card supports GL_TEXTURE_2D_ARRAY you can do part of the selection in the vertex shader by passing to the vertex shader which texture in the array to use which will reduce the texture sampling in the pixel shader but all textures in the array must share the same filtering setting.
On current GPUs you can have 16 or 32 textures bound at once (Depending on your definition of current GPU). You still want to minimize this for performance reasons by using atlases.